Skip to content

Add sidebar terminal modes - #3077

Closed
lawrencecchen wants to merge 2 commits into
mainfrom
feat-dual-sidebar-terminals-plan
Closed

lawrencecchen wants to merge 2 commits into
mainfrom
feat-dual-sidebar-terminals-plan

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Apr 21, 2026 •

Copy link
Copy Markdown
Contributor

Adds selectable terminal modes to the left and right sidebars.

What changed:

  • Adds a Workspaces/Terminal mode selector to the left sidebar.
  • Adds a Terminal tab to the right sidebar alongside Files and Sessions.
  • Adds optional left/right sidebar terminal config in cmux.json, with global config merged under local config.
  • Keeps sidebar terminals out of serialized workspace layout and Bonsplit surface counts.

Verification:

  • jq empty Resources/Localizable.xcstrings
  • git diff --check
  • ./scripts/reload.sh --tag sidebar-term

Summary by cubic

Add terminal modes to both sidebars for quick access to a per-side terminal. Supports per-side config in cmux.json (local overrides global) and keeps sidebar terminals out of Bonsplit layout and workspace serialization.

  • New Features

    • Left sidebar adds a Workspaces/Terminal switch; selection is remembered.
    • Right sidebar adds a Terminal tab alongside Files and Sessions.
    • cmux.json: optional sidebar.left/right.terminal with title, command, and cwd; global and local configs are merged.
    • Terminals use the selected workspace, inherit the focused panel’s cwd (overridable via cwd), and run command once on start.
  • Refactors

    • Sidebar terminals are excluded from Bonsplit surface counts, last-surface checks, and layout serialization.
    • Updated focus/close logic to handle sidebar terminals (AppDelegate, AppleScript, GhosttyTerminalView, TerminalController, Workspace).
    • Tagged sidebar resize UI tests to ensure reliable app activation during launch.

Written for commit 9588df6. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Added a Terminal mode for the left and right sidebars with persisted selection (Workspaces vs Terminal).
    • Sidebar terminals can be configured (left/right) and shown inline, with a “No workspace selected” placeholder when applicable.
    • Added English and Japanese localizations for Terminal/Workspaces labels and related UI text.
  • Tests

    • Added tests covering sidebar terminal configuration decoding and whitespace-trimming behavior.

@vercel

vercel Bot commented Apr 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Apr 21, 2026 11:15pm

@coderabbitai

coderabbitai Bot commented Apr 21, 2026 •

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 377ba929-1a42-4539-a91e-293c81242929

📥 Commits

Reviewing files that changed from the base of the PR and between 1eee5e9 and 9588df6.

📒 Files selected for processing (1)
  • cmuxUITests/SidebarResizeUITests.swift

📝 Walkthrough

Walkthrough

Adds sidebar-mounted terminal support: new cmux sidebar config and decoding, UI modes for left/right sidebars, SidebarTerminalPanelView and workspace lifecycle for sidebar terminals, focus/close logic updated to treat sidebar terminals separately, and four new localization strings.

Changes

Cohort / File(s) Summary
Localization Strings
Resources/Localizable.xcstrings
Added keys: leftSidebar.mode.terminal, leftSidebar.mode.workspaces, rightSidebar.mode.terminal, sidebar.terminal.noWorkspace with en/ja translations and ensured trailing newline.
Config & Store
Sources/CmuxConfig.swift
Added optional sidebar on CmuxConfigFile with custom decoding; new types SidebarTerminalPlacement, CmuxSidebarTerminalDefinition, CmuxSidebarSideDefinition, CmuxSidebarDefinition, CmuxSidebarConfiguration; CmuxConfigStore publishes sidebarConfiguration and merges global/local configs.
Left Sidebar UI
Sources/ContentView.swift
Introduced LeftSidebarMode, persisted mode UI, LeftSidebarModeBar/buttons; VerticalTabsSidebar now conditionally renders workspaces vs terminal mode; added SidebarTerminalPanelView and @EnvironmentObject cmuxConfigStore.
Right Sidebar UI
Sources/RightSidebarPanelView.swift
Added RightSidebarMode.terminal; uses cmuxConfigStore to render SidebarTerminalPanelView for selected workspace; shows localized sidebar.terminal.noWorkspace when none selected.
Workspace model & session
Sources/Workspace.swift
Track sidebar terminal panels per placement; add helpers isSidebarTerminalPanel, isBonsplitPanel, bonsplitPanelCount, hasBonsplitPanels; implement sidebarTerminalPanel(...), adjust ordering, close, and snapshot logic to exclude sidebar terminals.
Terminal focus & controller
Sources/GhosttyTerminalView.swift, Sources/TerminalController.swift, Sources/AppleScriptSupport.swift, Sources/AppDelegate.swift
Short-circuit focus/match logic for sidebar terminal panels; replace raw panels.count checks with bonsplit-aware counts (bonsplitPanelCount / hasBonsplitPanels); prevent sidebar panels from being treated as orphans.
Tests (config decoding)
cmuxTests/CmuxConfigTests.swift
Added tests for decoding sidebar terminal definitions and whitespace-trimming behavior.
UI Tests (helpers)
cmuxUITests/SidebarResizeUITests.swift
Refactored launch helper to set CMUX_TAG/CMUX_UI_TEST_MODE and ensure foreground after launch.

Sequence Diagram(s)

sequenceDiagram
    participant User as User/UI
    participant Config as CmuxConfigStore
    participant View as SidebarTerminalPanelView
    participant Workspace as Workspace
    participant Terminal as TerminalPanel

    User->>Config: App launch → load configs
    Config->>Config: Merge global + local sidebarConfiguration
    User->>View: Select sidebar mode (left/right)
    View->>Config: Request terminal config for placement
    Config-->>View: Return CmuxSidebarTerminalDefinition?
    View->>Workspace: sidebarTerminalPanel(for:placement, config, cwd)
    Workspace->>Workspace: Check cached SidebarTerminalCreationSignature
    alt signature matches
        Workspace-->>Terminal: Reuse existing sidebar terminal panel
    else
        Workspace->>Terminal: Create new sidebar terminal panel (inherit config, set placement tag)
    end
    Terminal-->>Workspace: Return TerminalPanel
    Workspace-->>View: Provide panel to render
    View->>Terminal: Render/attach in sidebar and manage focus/visibility
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐰 Hop to the side where terminals dwell,

left or right, they ring the bell.
Configs merged, and panels fine—
I’ll nibble keys while branches align. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title clearly and concisely describes the main change: adding sidebar terminal modes. It directly aligns with the primary objective of the changeset.
Description check ✅ Passed The pull request description addresses the key requirements: it explains what changed, why it matters, and includes verification steps. However, it lacks the required Testing section details and the Review Trigger checklist as specified in the template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-dual-sidebar-terminals-plan

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Apr 21, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds selectable terminal modes to both sidebars: the left sidebar gets a Workspaces / Terminal toggle (persisted in AppStorage), and the right sidebar gains a Terminal tab alongside Files and Sessions. Sidebar terminals are managed as a distinct panel kind (sidebarTerminalPanelIds) that is excluded from bonsplit surface counts, workspace serialization, and all close-guard checks — achieved by replacing panels.count / panels.isEmpty with new bonsplitPanelCount / hasBonsplitPanels helpers throughout Workspace, TerminalController, AppDelegate, and AppleScriptSupport. Config is read from cmux.json (sidebar.left.terminal / sidebar.right.terminal) with global/local merge, and panels are reused via a creation-signature cache keyed on startup command and working directory.

Confidence Score: 5/5

Safe to merge — all findings are P2 style/cleanup suggestions with no correctness or data-integrity impact.

The implementation is thorough: sidebar terminals are correctly excluded from all panel counts, close guards, serialization, and focus-tracking paths. Config merging, panel lifecycle (reuse via signature, cleanup), localization, and tests are all handled correctly. The three flagged items are a deprecated API call, a misleading fallback message, and a minor O(n) inefficiency — none block merge.

Sources/ContentView.swift (deprecated onChange API, misleading fallback message) and Sources/Workspace.swift (bonsplitPanelCount efficiency).

Important Files Changed

Filename Overview
Sources/Workspace.swift Core change: adds sidebar terminal panel lifecycle (create, reuse via signature, close) alongside new bonsplitPanelCount/hasBonsplitPanels helpers that gate all close/collapse guards. Logic is sound but bonsplitPanelCount iterates all panels on every call (minor perf concern flagged).
Sources/ContentView.swift Adds LeftSidebarMode enum/bar UI, SidebarTerminalPanelView, and wires left sidebar terminal into VerticalTabsSidebar. Two deprecated onChange(of:) API calls; fallback "No workspace selected" message is shown when workspace exists but has no bonsplit panes.
Sources/CmuxConfig.swift Adds CmuxSidebarDefinition, CmuxSidebarTerminalDefinition, CmuxSidebarConfiguration with proper merge semantics (local overrides global). normalizedOptionalString helper trims blanks to nil. Clean implementation.
Sources/RightSidebarPanelView.swift Adds terminal case to RightSidebarMode and renders SidebarTerminalPanelView with correct isVisibleInUI gated on fileExplorerState.isVisible && mode == .terminal.
Sources/GhosttyTerminalView.swift Focus-checking functions updated to short-circuit for sidebar terminal panels (bypassing bonsplitController pane lookup), allowing them to receive focus events correctly.
Sources/TerminalController.swift Replaces panels.count with bonsplitPanelCount in four close-guard checks and filters sidebar terminals from orphan panel ordering — all correct.
Sources/AppDelegate.swift Single guard updated from panels.isEmpty to !hasBonsplitPanels so sidebar terminals don't block workspace tab-close consolidation.
Sources/AppleScriptSupport.swift Single panels.count == 1 replaced with bonsplitPanelCount == 1 to exclude sidebar terminals from AppleScript close-workspace logic.
cmuxTests/CmuxConfigTests.swift Adds two well-scoped tests: sidebar terminal decoding without commands, and blank-field trimming to nil — good behavioral coverage of the new config types.
Resources/Localizable.xcstrings Adds en/ja translations for four new string keys (leftSidebar.mode.terminal/workspaces, rightSidebar.mode.terminal, sidebar.terminal.noWorkspace) — correctly follows the localization policy.

Sequence Diagram

sequenceDiagram
    participant SV as SidebarTerminalPanelView
    participant W as Workspace
    participant TP as TerminalPanel
    participant TV as TerminalPanelView

    SV->>SV: onAppear / onChange(workspace.id) / onChange(configuration)
    SV->>SV: compute preferredWorkingDirectory
    SV->>W: sidebarTerminalPanel(for:placement, configuration:, preferredWorkingDirectory:)
    W->>W: build SidebarTerminalCreationSignature
    alt existing panel & signature matches
        W->>W: applySidebarTerminalConfiguration (update title)
        W-->>SV: return existing TerminalPanel
    else signature changed or no panel
        W->>W: closeSidebarTerminalPanel (cleanup all metadata)
        W->>TP: TerminalPanel(workspaceId, GHOSTTY_SURFACE_CONTEXT_SPLIT, workingDirectory, initialInput, CMUX_SIDEBAR_TERMINAL env)
        W->>W: register in panels[], sidebarTerminalPanelIds[], sidebarTerminalCreationSignatures[]
        W-->>SV: return new TerminalPanel
    end
    SV->>TV: TerminalPanelView(panel, paneId, isFocused, isVisibleInUI)
    SV->>SV: onDisappear → setVisibleInUI(false) + setActive(false)
Loading

Comments Outside Diff (3)

  1. Sources/ContentView.swift, line 388-394 (link)

    P2 Deprecated onChange(of:) API

    Both onChange(of:) calls use the single-argument closure form that was deprecated in macOS 14 / SwiftUI 5.0. The compiler will emit a deprecation warning for each. The modern no-argument form drops the _ in:

  2. Sources/ContentView.swift, line 360-383 (link)

    P2 Misleading "No workspace selected" when workspace has no bonsplit panes

    paneId resolves to nil when workspace.bonsplitController.allPaneIds is empty — for example, immediately after all bonsplit panels are closed and before the workspace is torn down. In that edge case the workspace object is already passed in (it is selected), so showing "No workspace selected" is inaccurate and confusing to the user. A more precise fallback like "No terminal panels" or simply leaving the view empty would better reflect the real state.

  3. Sources/Workspace.swift, line 906-910 (link)

    P2 bonsplitPanelCount iterates all panels on every call

    This property does a full O(n) reduction over panels.keys and is now called from several hot paths (shouldCloseWorkspaceOnLastSurface, multiple TerminalController close-guard checks, AppDelegate tab-close guard, bonsplitDidRemoveTab). Because sidebarTerminalPanelIds stays in sync with panels through all lifecycle calls, the count can be derived more cheaply:

Reviews (1): Last reviewed commit: "Add sidebar terminal modes" | Re-trigger Greptile

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
Sources/Workspace.swift (3)

8378-8388: ⚠️ Potential issue | 🟠 Major

Clear local terminal metadata before remote promotion.

Line 8387 promotes an existing local terminal to remote-backed, but any previously reported local TTY/listening ports remain in surfaceTTYNames and surfaceListeningPorts. Clear them before tracking so stale local metadata is not shown or scanned as remote state.

Suggested fix
         }
         guard terminalIds.count == 1, let initialPanelId = terminalIds.first else { return }
+        surfaceTTYNames.removeValue(forKey: initialPanelId)
+        surfaceListeningPorts.removeValue(forKey: initialPanelId)
+        recomputeListeningPorts()
         trackRemoteTerminalSurface(initialPanelId)
     }

Based on learnings, In Sources/Workspace.swift: When promoting an existing local terminal to remote-backed in Workspace.seedInitialRemoteTerminalSessionIfNeeded(), clear both surfaceTTYNames[panelId] and surfaceListeningPorts[panelId] before calling trackRemoteTerminalSurface(:) to avoid stale local TTY/port metadata in the workspace UI.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 8378 - 8388, In
seedInitialRemoteTerminalSessionIfNeeded(configuration:), before calling
trackRemoteTerminalSurface(initialPanelId) clear any stale local metadata by
removing entries for the panelId from surfaceTTYNames and surfaceListeningPorts
(e.g. surfaceTTYNames[initialPanelId] = nil;
surfaceListeningPorts[initialPanelId] = nil) so that previously-reported local
TTY names and listening ports are not shown or scanned after promoting the panel
to remote; keep the existing guards that locate terminalIds and
activeRemoteTerminalSurfaceIds and perform the clearing just prior to invoking
trackRemoteTerminalSurface(initialPanelId).

8163-8166: ⚠️ Potential issue | 🟠 Major

Keep sidebar terminal TTYs out of remote port scans and refresh ports on close.

syncRemotePortScanTTYs() still forwards every surfaceTTYNames entry, so a local sidebar terminal in a remote workspace can be treated as a remote-scanned panel. Also, closeSidebarTerminalPanel(_:) removes surfaceListeningPorts without recomputing listeningPorts, leaving stale ports visible until another update.

Suggested fix
     func syncRemotePortScanTTYs() {
         guard isRemoteWorkspace else { return }
-        remoteSessionController?.updateRemotePortScanTTYs(surfaceTTYNames)
+        let remoteTTYNames = surfaceTTYNames.filter { panelId, _ in
+            !isSidebarTerminalPanel(panelId)
+        }
+        remoteSessionController?.updateRemotePortScanTTYs(remoteTTYNames)
     }
         surfaceTTYNames.removeValue(forKey: panelId)
         surfaceListeningPorts.removeValue(forKey: panelId)
+        remoteDetectedSurfaceIds.remove(panelId)
         restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
         terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId)
         sidebarTerminalPanelIds.removeValue(forKey: placement)
         sidebarTerminalCreationSignatures.removeValue(forKey: placement)
         PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId)
         AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: id, surfaceId: panelId)
+        syncRemotePortScanTTYs()
+        recomputeListeningPorts()
         return true
     }

Also applies to: 7356-7381

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 8163 - 8166, syncRemotePortScanTTYs
currently forwards all surfaceTTYNames to
remoteSessionController.updateRemotePortScanTTYs, which causes local sidebar
terminals to be treated as remote-scanned TTYs; change syncRemotePortScanTTYs to
filter out sidebar terminal names (e.g. exclude entries that originate from the
sidebar) before calling remoteSessionController.updateRemotePortScanTTYs.
Additionally, in closeSidebarTerminalPanel(_:) after removing entries from
surfaceListeningPorts, recompute and refresh the aggregated listeningPorts (or
call the existing update path that sets listeningPorts from
surfaceListeningPorts) so stale ports are not left visible; ensure you reference
and update surfaceListeningPorts and listeningPorts in that flow so the UI is
refreshed when a sidebar terminal is closed.

7958-7975: ⚠️ Potential issue | 🟡 Minor

Filter sidebar terminals out of workspace metadata ordering.

Because sidebar terminals are stored in panels but have no Bonsplit tab, Line 7968 adds them as fallback IDs. That lets sidebar-terminal cwd/branch/PR state leak into the workspace directory/branch/PR lists. Keep this order Bonsplit-only.

Suggested fix
-        let fallbackPanelIds = panels.keys.sorted { $0.uuidString < $1.uuidString }
+        let fallbackPanelIds = panels.keys
+            .filter(isBonsplitPanel)
+            .sorted { $0.uuidString < $1.uuidString }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 7958 - 7975, The current
sidebarOrderedPanelIds() uses panels.keys as fallbackPanelIds, which includes
sidebar-only terminals and leaks their state; instead compute fallbackPanelIds
from the Bonsplit-derived paneTabs so the ordering is Bonsplit-only. In
sidebarOrderedPanelIds(), replace the fallbackPanelIds assignment with a value
built from paneTabs' values (flattened, deduplicated, converted back to UUIDs
and sorted by uuidString) so only panel IDs that appear in bonsplitController
tabs are used when calling SidebarBranchOrdering.orderedPanelIds.
🧹 Nitpick comments (2)
Sources/ContentView.swift (2)

10594-10736: DRY the mode bar and empty-state placeholder across branches.

The two branches of the mode switch duplicate:

  • The traffic-light spacer + LeftSidebarModeBar block (Lines 10598-10606 vs 10709-10716), including padding and the leftSidebarMode = mode.rawValue closure.
  • The “No workspace selected” text (Line 10729) is also the exact fallback already emitted inside SidebarTerminalPanelView (Lines 10478-10482), so the outer if let workspace = tabManager.selectedWorkspace branch and the inner else branch will never coexist — one of them is dead code depending on which path is reached first.

Recommend extracting a small leftSidebarModeHeader view and either (a) removing the outer selectedWorkspace guard and always rendering SidebarTerminalPanelView (handing it an optional workspace), or (b) removing the inner fallback from SidebarTerminalPanelView so the placeholder lives in exactly one place.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 10594 - 10736, The LeftSidebarModeBar
+ Spacer block is duplicated between the workspaces branch and the other branch
(see LeftSidebarModeBar and the Spacer using trafficLightPadding and the closure
that sets leftSidebarMode = mode.rawValue) and the "No workspace selected"
placeholder is duplicated between the outer if-let tabManager.selectedWorkspace
and the inner fallback inside SidebarTerminalPanelView; extract a small
leftSidebarModeHeader view (containing Spacer and LeftSidebarModeBar with the
same padding/closure) and use it in both branches, then choose one of the two
suggested fixes for the placeholder: either remove the outer if-let and always
render SidebarTerminalPanelView by changing its API to accept an optional
workspace (refactor SidebarTerminalPanelView to handle nil), or remove the
internal fallback text from SidebarTerminalPanelView so the placeholder lives
only where the outer branch renders it (keep tabManager.selectedWorkspace
guard); update references to SidebarTerminalPanelView, LeftSidebarModeBar,
leftSidebarMode, and tabManager.selectedWorkspace accordingly.

10488-10494: Migrate to two-parameter or zero-parameter onChange(of:) closure.

Both .onChange(of: workspace.id) and .onChange(of: configuration) use the deprecated single-parameter form (macOS 14+). Since neither closure uses the value parameter, they can be simplified to zero-parameter closures for consistency with modern SwiftUI patterns:

.onChange(of: workspace.id) {
    panel = nil
    ensurePanel()
}
.onChange(of: configuration) {
    ensurePanel()
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 10488 - 10494, The two .onChange
calls using the deprecated single-parameter closure should be converted to
zero-parameter closures since the value isn't used: replace the .onChange(of:
workspace.id) { _ in ... } and .onChange(of: configuration) { _ in ... } forms
with zero-parameter closures that drop the unused parameter and keep the bodies
(for workspace.id set panel = nil then call ensurePanel(), and for configuration
just call ensurePanel()); update the occurrences around the ensurePanel() calls
in ContentView where .onChange(of: workspace.id) and .onChange(of:
configuration) are declared.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/CmuxConfig.swift`:
- Around line 82-86: In merging(overrides: CmuxSidebarConfiguration) update the
leftTerminal/rightTerminal merge to deep-merge terminal fields instead of
replacing the whole terminal object: when overrides.leftTerminal/rightTerminal
is non-nil, produce a new CmuxTerminal whose individual properties (e.g.,
command, title, cwd) take overrides' values when present and fall back to the
existing leftTerminal/rightTerminal values otherwise; if
overrides.leftTerminal/rightTerminal is nil, keep the original terminal
unchanged. Reference the merging(overrides:) function and the
leftTerminal/rightTerminal properties and merge each terminal field explicitly
rather than using the full-object nil-coalescing currently in place.

In `@Sources/ContentView.swift`:
- Around line 10459-10476: The sidebar terminal currently uses isFocused:
isVisibleInUI which makes visibility equal focus; change this to track real
focus state by adding a local `@State` private var hasFocus = false and pass
isFocused: hasFocus to TerminalPanelView (instead of isVisibleInUI), set
hasFocus = true inside the existing onFocus closure (which already calls
panel.surface.setFocus and requestBackgroundSurfaceStartIfNeeded), and clear
hasFocus (set to false) in an onDisappear handler and whenever the workspace's
actual focusedPaneId changes away from paneId (or when you receive a
first-responder change) so the GhosttyTerminalView/portal only activates when
the sidebar truly has focus.
- Around line 10459-10513: The code currently assigns an arbitrary bonsplit
paneId in the view's if-let (using workspace.bonsplitController.focusedPaneId ??
workspace.bonsplitController.allPaneIds.first) which leaks a Bonsplit-managed id
into the sidebar terminal's portal-hosting lifecycle; instead, use the
sidebar-specific stable id already provided by Workspace
(sidebarTerminalPanelIds[placement] or a small accessor like
sidebarTerminalPanelId(for: placement)) and pass that into TerminalPanelView as
paneId so the sidebar terminal uses a dedicated, stable paneId that won't be
tracked/modified by Bonsplit host assignment; update the conditional and any
references to paneId in this view (and ensurePanel logic if it derives pane ids)
to use the sidebar-specific id rather than bonsplitController values.
- Around line 10485-10502: In SidebarTerminalPanelView update lifecycle
handling: in the .onAppear block after ensurePanel() re-assert the hosted view's
UI state by calling panel?.hostedView.setVisibleInUI(true) and
panel?.hostedView.setActive(true); in the .onChange(of: workspace.id) handler
before setting panel = nil deactivate the previous panel by calling
panel?.hostedView.setVisibleInUI(false) and panel?.hostedView.setActive(false);
leave the .onChange(of: configuration) logic but double-check whether
applySidebarTerminalConfiguration should be invoked for mutable fields
(cwd/command) in sidebarTerminalPanel()/applySidebarTerminalConfiguration() if
configuration changes must be applied mid-session.

In `@Sources/GhosttyTerminalView.swift`:
- Around line 10791-10804: The current early-return for sidebar terminals (using
isSidebarTerminalPanel(surfaceId)) must be gated by workspace.focusedPanelId ==
terminalSurface.id so we only short-circuit when this sidebar panel is the
workspace’s focused panel; update the checks around
isSidebarTerminalPanel(surfaceId) and matchesCurrentTerminalFocusTarget to also
verify workspace.focusedPanelId equals the terminal surface id (use
workspace.focusedPanelId and terminalSurface.id) before skipping focus logic,
and apply the same change to the other similar block (around lines 10862-10864)
so scheduleAutomaticFirstResponderApply(reason:) / applyFirstResponderIfNeeded()
won’t steal focus from the other visible sidebar terminal.

In `@Sources/Workspace.swift`:
- Around line 7328-7333: The reconciliation loop that hides panels uses
renderedVisiblePanelIdsForCurrentLayout() and thus can mark sidebar terminals
(stored via panels[terminalPanel.id] and sidebarTerminalPanelIds) as hidden
because they never appear in the Bonsplit layout; update the reconciliation
logic (the code that iterates visible IDs during Bonsplit portal reconciliation)
to treat sidebar terminals as always-visible by checking sidebarTerminalPanelIds
and sidebarTerminalCreationSignatures (or by skipping panels whose id exists in
sidebarTerminalPanelIds) before setting them hidden, so sidebarTerminal panels
created in functions like seedTerminalInheritanceFontPoints and returned from
that creation path are never toggled off during layout follow-ups.
- Around line 7345-7354: applySidebarTerminalConfiguration currently only sets
non-empty configured titles, leaving stale titles when configuration title is
removed; update the function to handle the nil/empty case by explicitly
resetting the panel title and cache: when configuration?.title is nil or empty,
call panel.updateTitle with the panel's default/display title (or clear it) and
remove any entry from panelTitles for panel.id so the old title is not retained;
keep the existing behavior for non-empty titles that sets
panel.updateTitle(title) and panelTitles[panel.id] = panel.displayTitle.

---

Outside diff comments:
In `@Sources/Workspace.swift`:
- Around line 8378-8388: In
seedInitialRemoteTerminalSessionIfNeeded(configuration:), before calling
trackRemoteTerminalSurface(initialPanelId) clear any stale local metadata by
removing entries for the panelId from surfaceTTYNames and surfaceListeningPorts
(e.g. surfaceTTYNames[initialPanelId] = nil;
surfaceListeningPorts[initialPanelId] = nil) so that previously-reported local
TTY names and listening ports are not shown or scanned after promoting the panel
to remote; keep the existing guards that locate terminalIds and
activeRemoteTerminalSurfaceIds and perform the clearing just prior to invoking
trackRemoteTerminalSurface(initialPanelId).
- Around line 8163-8166: syncRemotePortScanTTYs currently forwards all
surfaceTTYNames to remoteSessionController.updateRemotePortScanTTYs, which
causes local sidebar terminals to be treated as remote-scanned TTYs; change
syncRemotePortScanTTYs to filter out sidebar terminal names (e.g. exclude
entries that originate from the sidebar) before calling
remoteSessionController.updateRemotePortScanTTYs. Additionally, in
closeSidebarTerminalPanel(_:) after removing entries from surfaceListeningPorts,
recompute and refresh the aggregated listeningPorts (or call the existing update
path that sets listeningPorts from surfaceListeningPorts) so stale ports are not
left visible; ensure you reference and update surfaceListeningPorts and
listeningPorts in that flow so the UI is refreshed when a sidebar terminal is
closed.
- Around line 7958-7975: The current sidebarOrderedPanelIds() uses panels.keys
as fallbackPanelIds, which includes sidebar-only terminals and leaks their
state; instead compute fallbackPanelIds from the Bonsplit-derived paneTabs so
the ordering is Bonsplit-only. In sidebarOrderedPanelIds(), replace the
fallbackPanelIds assignment with a value built from paneTabs' values (flattened,
deduplicated, converted back to UUIDs and sorted by uuidString) so only panel
IDs that appear in bonsplitController tabs are used when calling
SidebarBranchOrdering.orderedPanelIds.

---

Nitpick comments:
In `@Sources/ContentView.swift`:
- Around line 10594-10736: The LeftSidebarModeBar + Spacer block is duplicated
between the workspaces branch and the other branch (see LeftSidebarModeBar and
the Spacer using trafficLightPadding and the closure that sets leftSidebarMode =
mode.rawValue) and the "No workspace selected" placeholder is duplicated between
the outer if-let tabManager.selectedWorkspace and the inner fallback inside
SidebarTerminalPanelView; extract a small leftSidebarModeHeader view (containing
Spacer and LeftSidebarModeBar with the same padding/closure) and use it in both
branches, then choose one of the two suggested fixes for the placeholder: either
remove the outer if-let and always render SidebarTerminalPanelView by changing
its API to accept an optional workspace (refactor SidebarTerminalPanelView to
handle nil), or remove the internal fallback text from SidebarTerminalPanelView
so the placeholder lives only where the outer branch renders it (keep
tabManager.selectedWorkspace guard); update references to
SidebarTerminalPanelView, LeftSidebarModeBar, leftSidebarMode, and
tabManager.selectedWorkspace accordingly.
- Around line 10488-10494: The two .onChange calls using the deprecated
single-parameter closure should be converted to zero-parameter closures since
the value isn't used: replace the .onChange(of: workspace.id) { _ in ... } and
.onChange(of: configuration) { _ in ... } forms with zero-parameter closures
that drop the unused parameter and keep the bodies (for workspace.id set panel =
nil then call ensurePanel(), and for configuration just call ensurePanel());
update the occurrences around the ensurePanel() calls in ContentView where
.onChange(of: workspace.id) and .onChange(of: configuration) are declared.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1e3b4376-6a71-4cc1-bba3-c3d3b7a45373

📥 Commits

Reviewing files that changed from the base of the PR and between 1a95cbb and 1eee5e9.

📒 Files selected for processing (10)
  • Resources/Localizable.xcstrings
  • Sources/AppDelegate.swift
  • Sources/AppleScriptSupport.swift
  • Sources/CmuxConfig.swift
  • Sources/ContentView.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/RightSidebarPanelView.swift
  • Sources/TerminalController.swift
  • Sources/Workspace.swift
  • cmuxTests/CmuxConfigTests.swift

Comment thread Sources/CmuxConfig.swift
Comment on lines +82 to +86
func merging(overrides: CmuxSidebarConfiguration) -> CmuxSidebarConfiguration {
CmuxSidebarConfiguration(
leftTerminal: overrides.leftTerminal ?? leftTerminal,
rightTerminal: overrides.rightTerminal ?? rightTerminal
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Deep-merge terminal definitions instead of replacing them.

With the current merge, a local config like terminal: { "cwd": "app" } replaces the whole global terminal and drops its command/title, so “global config merged under local config” only works at the side level.

Suggested partial-override merge
 struct CmuxSidebarTerminalDefinition: Codable, Sendable, Equatable {
@@
     private static func normalizedOptionalString(_ value: String?) -> String? {
         guard let value else { return nil }
         let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
         return trimmed.isEmpty ? nil : trimmed
     }
+
+    func merging(overrides: CmuxSidebarTerminalDefinition) -> CmuxSidebarTerminalDefinition {
+        CmuxSidebarTerminalDefinition(
+            title: overrides.title ?? title,
+            command: overrides.command ?? command,
+            cwd: overrides.cwd ?? cwd
+        )
+    }
 }
@@
 struct CmuxSidebarConfiguration: Sendable, Equatable {
@@
+    private static func mergeTerminal(
+        _ base: CmuxSidebarTerminalDefinition?,
+        overrides: CmuxSidebarTerminalDefinition?
+    ) -> CmuxSidebarTerminalDefinition? {
+        guard let overrides else { return base }
+        guard let base else { return overrides }
+        return base.merging(overrides: overrides)
+    }
+
     func merging(overrides: CmuxSidebarConfiguration) -> CmuxSidebarConfiguration {
         CmuxSidebarConfiguration(
-            leftTerminal: overrides.leftTerminal ?? leftTerminal,
-            rightTerminal: overrides.rightTerminal ?? rightTerminal
+            leftTerminal: Self.mergeTerminal(leftTerminal, overrides: overrides.leftTerminal),
+            rightTerminal: Self.mergeTerminal(rightTerminal, overrides: overrides.rightTerminal)
         )
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/CmuxConfig.swift` around lines 82 - 86, In merging(overrides:
CmuxSidebarConfiguration) update the leftTerminal/rightTerminal merge to
deep-merge terminal fields instead of replacing the whole terminal object: when
overrides.leftTerminal/rightTerminal is non-nil, produce a new CmuxTerminal
whose individual properties (e.g., command, title, cwd) take overrides' values
when present and fall back to the existing leftTerminal/rightTerminal values
otherwise; if overrides.leftTerminal/rightTerminal is nil, keep the original
terminal unchanged. Reference the merging(overrides:) function and the
leftTerminal/rightTerminal properties and merge each terminal field explicitly
rather than using the full-object nil-coalescing currently in place.

Comment thread Sources/ContentView.swift
Comment on lines +10459 to +10476
var body: some View {
Group {
if let panel, let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first {
TerminalPanelView(
panel: panel,
paneId: paneId,
isFocused: isVisibleInUI,
isVisibleInUI: isVisibleInUI,
portalPriority: 4,
isSplit: false,
appearance: PanelAppearance.fromConfig(config),
hasUnreadNotification: false,
onFocus: {
panel.surface.setFocus(true)
panel.surface.requestBackgroundSurfaceStartIfNeeded()
},
onTriggerFlash: {}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

❓ Verification inconclusive

Script executed:

#!/bin/bash
# Inspect how TerminalPanelView consumes isFocused to confirm whether passing
# true unconditionally causes focus/first-responder side effects.
ast-grep --pattern $'struct TerminalPanelView: View {
  $$$
}'
rg -nP --type=swift -C3 '\bisFocused\b' -g 'Sources/**'

Repository: manaflow-ai/cmux


Repository: manaflow-ai/cmux
Exit code: 0

stdout:

Sources/WorkspaceContentView.swift-246-    static func panelVisibleInUI(
Sources/WorkspaceContentView.swift-247-        isWorkspaceVisible: Bool,
Sources/WorkspaceContentView.swift-248-        isSelectedInPane: Bool,
Sources/WorkspaceContentView.swift:249:        isFocused: Bool
Sources/WorkspaceContentView.swift-250-    ) -> Bool {
Sources/WorkspaceContentView.swift-251-        guard isWorkspaceVisible else { return false }
Sources/WorkspaceContentView.swift-252-        // During pane/tab reparenting, Bonsplit can transiently report selected=false
Sources/WorkspaceContentView.swift-253-        // for the currently focused panel. Keep focused content visible to avoid blank frames.
Sources/WorkspaceContentView.swift:254:        return isSelectedInPane || isFocused
Sources/WorkspaceContentView.swift-255-    }
Sources/WorkspaceContentView.swift-256-
Sources/WorkspaceContentView.swift-257-    var body: some View {
--
Sources/WorkspaceContentView.swift-282-            // Content for each tab in bonsplit
Sources/WorkspaceContentView.swift-283-            let _ = Self.debugPanelLookup(tab: tab, workspace: workspace)
Sources/WorkspaceContentView.swift-284-            if let panel = workspace.panel(for: tab.id) {
Sources/WorkspaceContentView.swift:285:                let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id
Sources/WorkspaceContentView.swift-286-                let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
Sources/WorkspaceContentView.swift-287-                let isVisibleInUI = Self.panelVisibleInUI(
Sources/WorkspaceContentView.swift-288-                    isWorkspaceVisible: isWorkspaceVisible,
Sources/WorkspaceContentView.swift-289-                    isSelectedInPane: isSelectedInPane,
Sources/WorkspaceContentView.swift:290:                    isFocused: isFocused
Sources/WorkspaceContentView.swift-291-                )
Sources/WorkspaceContentView.swift-292-                let showsNotificationRing = Workspace.shouldShowUnreadIndicator(
Sources/WorkspaceContentView.swift-293-                    hasUnreadNotification: notificationStore.hasVisibleNotificationIndicator(
--
Sources/WorkspaceContentView.swift-299-                PanelContentView(
Sources/WorkspaceContentView.swift-300-                    panel: panel,
Sources/WorkspaceContentView.swift-301-                    paneId: paneId,
Sources/WorkspaceContentView.swift:302:                    isFocused: isFocused,
Sources/WorkspaceContentView.swift-303-                    isSelectedInPane: isSelectedInPane,
Sources/WorkspaceContentView.swift-304-                    isVisibleInUI: isVisibleInUI,
Sources/WorkspaceContentView.swift-305-                    portalPriority: workspacePortalPriority,
--
Sources/Panels/TerminalPanelView.swift-9-    `@AppStorage`(NotificationPaneRingSettings.enabledKey)
Sources/Panels/TerminalPanelView.swift-10-    private var notificationPaneRingEnabled = NotificationPaneRingSettings.defaultEnabled
Sources/Panels/TerminalPanelView.swift-11-    let paneId: PaneID
Sources/Panels/TerminalPanelView.swift:12:    let isFocused: Bool
Sources/Panels/TerminalPanelView.swift-13-    let isVisibleInUI: Bool
Sources/Panels/TerminalPanelView.swift-14-    let portalPriority: Int
Sources/Panels/TerminalPanelView.swift-15-    let isSplit: Bool
--
Sources/Panels/TerminalPanelView.swift-24-        GhosttyTerminalView(
Sources/Panels/TerminalPanelView.swift-25-            terminalSurface: panel.surface,
Sources/Panels/TerminalPanelView.swift-26-            paneId: paneId,
Sources/Panels/TerminalPanelView.swift:27:            isActive: isFocused,
Sources/Panels/TerminalPanelView.swift-28-            isVisibleInUI: isVisibleInUI,
Sources/Panels/TerminalPanelView.swift-29-            portalZPriority: portalPriority,
Sources/Panels/TerminalPanelView.swift:30:            showsInactiveOverlay: isSplit && !isFocused,
Sources/Panels/TerminalPanelView.swift-31-            showsUnreadNotificationRing: hasUnreadNotification && notificationPaneRingEnabled,
Sources/Panels/TerminalPanelView.swift-32-            inactiveOverlayColor: appearance.unfocusedOverlayNSColor,
Sources/Panels/TerminalPanelView.swift-33-            inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity,
--
Sources/TabManager.swift-4436-            ReactGrabShortcutPanelSnapshot(
Sources/TabManager.swift-4437-                id: panel.id,
Sources/TabManager.swift-4438-                panelType: panel.panelType,
Sources/TabManager.swift:4439:                isFocused: panel.id == workspace.focusedPanelId
Sources/TabManager.swift-4440-            )
Sources/TabManager.swift-4441-        }
Sources/TabManager.swift-4442-        guard let route = resolveReactGrabShortcutRoute(panels: snapshots),
--
Sources/Panels/BrowserPanelView.swift-385-    `@ObservedObject` var panel: BrowserPanel
Sources/Panels/BrowserPanelView.swift-386-    `@ObservedObject` private var browserProfileStore = BrowserProfileStore.shared
Sources/Panels/BrowserPanelView.swift-387-    let paneId: PaneID
Sources/Panels/BrowserPanelView.swift:388:    let isFocused: Bool
Sources/Panels/BrowserPanelView.swift-389-    let isVisibleInUI: Bool
Sources/Panels/BrowserPanelView.swift-390-    let portalPriority: Int
Sources/Panels/BrowserPanelView.swift-391-    let onRequestPanelFocus: () -> Void
--
Sources/Panels/BrowserPanelView.swift-621-#if DEBUG
Sources/Panels/BrowserPanelView.swift-622-            dlog(
Sources/Panels/BrowserPanelView.swift-623-                "browser.focus.clickIntent panel=\(panel.id.uuidString.prefix(5)) " +
Sources/Panels/BrowserPanelView.swift:624:                "isFocused=\(isFocused ? 1 : 0) " +
Sources/Panels/BrowserPanelView.swift-625-                "addressFocused=\(addressBarFocused ? 1 : 0)"
Sources/Panels/BrowserPanelView.swift-626-            )
Sources/Panels/BrowserPanelView.swift-627-#endif
--
Sources/Panels/BrowserPanelView.swift-631-#endif
Sources/Panels/BrowserPanelView.swift-632-                setAddressBarFocused(false, reason: "webView.clickIntent")
Sources/Panels/BrowserPanelView.swift-633-            }
Sources/Panels/BrowserPanelView.swift:634:            if !isFocused {
Sources/Panels/BrowserPanelView.swift-635-                onRequestPanelFocus()
Sources/Panels/BrowserPanelView.swift-636-            }
Sources/Panels/BrowserPanelView.swift-637-        }
--
Sources/Panels/BrowserPanelView.swift-737-            // an attached-inspector X-close.
Sources/Panels/BrowserPanelView.swift-738-            panel.scheduleDeveloperToolsVisibilityLossCheck()
Sources/Panels/BrowserPanelView.swift-739-        }
Sources/Panels/BrowserPanelView.swift:740:        .onChange(of: isFocused) { focused in
Sources/Panels/BrowserPanelView.swift-741-#if DEBUG
Sources/Panels/BrowserPanelView.swift-742-            logBrowserFocusState(
Sources/Panels/BrowserPanelView.swift-743-                event: "panelFocus.onChange",
--
Sources/Panels/BrowserPanelView.swift-778-                NotificationCenter.default.post(name: .browserDidFocusAddressBar, object: panel.id)
Sources/Panels/BrowserPanelView.swift-779-                // Only request panel focus if this pane isn't currently focused. When already
Sources/Panels/BrowserPanelView.swift-780-                // focused (e.g. Cmd+L), forcing focus can steal first responder back to WebKit.
Sources/Panels/BrowserPanelView.swift:781:                if !isFocused {
Sources/Panels/BrowserPanelView.swift-782-#if DEBUG
Sources/Panels/BrowserPanelView.swift-783-                    logBrowserFocusState(event: "addressBarFocus.requestPanelFocus")
Sources/Panels/BrowserPanelView.swift-784-#endif
--
Sources/Panels/BrowserPanelView.swift-1176-                        refreshInlineCompletion()
Sources/Panels/BrowserPanelView.swift-1177-                    }
Sources/Panels/BrowserPanelView.swift-1178-                ),
Sources/Panels/BrowserPanelView.swift:1179:                isFocused: $addressBarFocused,
Sources/Panels/BrowserPanelView.swift-1180-                inlineCompletion: inlineCompletion,
Sources/Panels/BrowserPanelView.swift-1181-                placeholder: String(localized: "browser.addressBar.placeholder", defaultValue: "Search or enter URL"),
Sources/Panels/BrowserPanelView.swift-1182-                onTap: {
--
Sources/Panels/BrowserPanelView.swift-1257-                    paneId: paneId,
Sources/Panels/BrowserPanelView.swift-1258-                    shouldAttachWebView: isVisibleInUI && isCurrentPaneOwner && !useLocalInlineDeveloperToolsHosting,
Sources/Panels/BrowserPanelView.swift-1259-                    useLocalInlineHosting: useLocalInlineDeveloperToolsHosting,
Sources/Panels/BrowserPanelView.swift:1260:                    shouldFocusWebView: isFocused && !addressBarFocused,
Sources/Panels/BrowserPanelView.swift:1261:                    isPanelFocused: isFocused,
Sources/Panels/BrowserPanelView.swift-1262-                    portalZPriority: portalPriority,
Sources/Panels/BrowserPanelView.swift-1263-                    paneDropZone: paneDropZone,
Sources/Panels/BrowserPanelView.swift-1264-                    searchOverlay: panel.searchState.map { searchState in
--
Sources/Panels/BrowserPanelView.swift-1359-        isPanelFocusedOverride: Bool? = nil
Sources/Panels/BrowserPanelView.swift-1360-    ) {
Sources/Panels/BrowserPanelView.swift-1361-        guard let cmuxWebView = panel.webView as? CmuxWebView else { return }
Sources/Panels/BrowserPanelView.swift:1362:        let isPanelFocused = isPanelFocusedOverride ?? isFocused
Sources/Panels/BrowserPanelView.swift-1363-        let next = isPanelFocused && !panel.shouldSuppressWebViewFocus()
Sources/Panels/BrowserPanelView.swift-1364-        if cmuxWebView.allowsFirstResponderAcquisition != next {
Sources/Panels/BrowserPanelView.swift-1365-#if DEBUG
--
Sources/Panels/BrowserPanelView.swift-1448-        let webResponder = browserFocusResponderChainContains(firstResponder, target: panel.webView) ? 1 : 0
Sources/Panels/BrowserPanelView.swift-1449-        var line =
Sources/Panels/BrowserPanelView.swift-1450-            "browser.focus.trace event=\(event) panel=\(panel.id.uuidString.prefix(5)) " +
Sources/Panels/BrowserPanelView.swift:1451:            "panelFocused=\(isFocused ? 1 : 0) addrFocused=\(addressBarFocused ? 1 : 0) " +
Sources/Panels/BrowserPanelView.swift-1452-            "suppressWeb=\(panel.shouldSuppressWebViewFocus() ? 1 : 0) " +
Sources/Panels/BrowserPanelView.swift-1453-            "suppressAuto=\(panel.shouldSuppressOmnibarAutofocus() ? 1 : 0) " +
Sources/Panels/BrowserPanelView.swift-1454-            "webResponder=\(webResponder) win=\(window?.windowNumber ?? -1) fr=\(firstResponderType)"
--
Sources/Panels/BrowserPanelView.swift-1731-    }
Sources/Panels/BrowserPanelView.swift-1732-
Sources/Panels/BrowserPanelView.swift-1733-    private func autoFocusOmnibarIfBlank() {
Sources/Panels/BrowserPanelView.swift:1734:        guard isFocused else {
Sources/Panels/BrowserPanelView.swift-1735-#if DEBUG
Sources/Panels/BrowserPanelView.swift-1736-            logBrowserFocusState(event: "addressBarFocus.autoFocus.skip", detail: "reason=panel_not_focused")
Sources/Panels/BrowserPanelView.swift-1737-#endif
--
Sources/Panels/BrowserPanelView.swift-1975-        inlineCompletion = omnibarInlineCompletionForDisplay(
Sources/Panels/BrowserPanelView.swift-1976-            typedText: omnibarState.buffer,
Sources/Panels/BrowserPanelView.swift-1977-            suggestions: omnibarState.suggestions,
Sources/Panels/BrowserPanelView.swift:1978:            isFocused: addressBarFocused,
Sources/Panels/BrowserPanelView.swift-1979-            selectionRange: omnibarSelectionRange,
Sources/Panels/BrowserPanelView.swift-1980-            hasMarkedText: omnibarHasMarkedText
Sources/Panels/BrowserPanelView.swift-1981-        )
--
Sources/Panels/BrowserPanelView.swift-2778-func omnibarInlineCompletionForDisplay(
Sources/Panels/BrowserPanelView.swift-2779-    typedText: String,
Sources/Panels/BrowserPanelView.swift-2780-    suggestions: [OmnibarSuggestion],
Sources/Panels/BrowserPanelView.swift:2781:    isFocused: Bool,
Sources/Panels/BrowserPanelView.swift-2782-    selectionRange: NSRange,
Sources/Panels/BrowserPanelView.swift-2783-    hasMarkedText: Bool
Sources/Panels/BrowserPanelView.swift-2784-) -> OmnibarInlineCompletion? {
Sources/Panels/BrowserPanelView.swift:2785:    guard isFocused else { return nil }
Sources/Panels/BrowserPanelView.swift-2786-    guard !hasMarkedText else { return nil }
Sources/Panels/BrowserPanelView.swift-2787-
Sources/Panels/BrowserPanelView.swift-2788-    let query = typedText.trimmingCharacters(in: .whitespacesAndNewlines)
--
Sources/Panels/BrowserPanelView.swift-3001-// MARK: - Omnibar State Machine
Sources/Panels/BrowserPanelView.swift-3002-
Sources/Panels/BrowserPanelView.swift-3003-struct OmnibarState: Equatable {
Sources/Panels/BrowserPanelView.swift:3004:    var isFocused: Bool = false
Sources/Panels/BrowserPanelView.swift-3005-    var currentURLString: String = ""
Sources/Panels/BrowserPanelView.swift-3006-    var buffer: String = ""
Sources/Panels/BrowserPanelView.swift-3007-    var suggestions: [OmnibarSuggestion] = []
--
Sources/Panels/BrowserPanelView.swift-3034-
Sources/Panels/BrowserPanelView.swift-3035-    switch event {
Sources/Panels/BrowserPanelView.swift-3036-    case .focusGained(let url):
Sources/Panels/BrowserPanelView.swift:3037:        state.isFocused = true
Sources/Panels/BrowserPanelView.swift-3038-        state.currentURLString = url
Sources/Panels/BrowserPanelView.swift-3039-        state.buffer = url
Sources/Panels/BrowserPanelView.swift-3040-        state.isUserEditing = false
--
Sources/Panels/BrowserPanelView.swift-3044-        effects.shouldSelectAll = true
Sources/Panels/BrowserPanelView.swift-3045-
Sources/Panels/BrowserPanelView.swift-3046-    case .focusLostRevertBuffer(let url):
Sources/Panels/BrowserPanelView.swift:3047:        state.isFocused = false
Sources/Panels/BrowserPanelView.swift-3048-        state.currentURLString = url
Sources/Panels/BrowserPanelView.swift-3049-        state.buffer = url
Sources/Panels/BrowserPanelView.swift-3050-        state.isUserEditing = false
--
Sources/Panels/BrowserPanelView.swift-3053-        state.selectedSuggestionID = nil
Sources/Panels/BrowserPanelView.swift-3054-
Sources/Panels/BrowserPanelView.swift-3055-    case .focusLostPreserveBuffer(let url):
Sources/Panels/BrowserPanelView.swift:3056:        state.isFocused = false
Sources/Panels/BrowserPanelView.swift-3057-        state.currentURLString = url
Sources/Panels/BrowserPanelView.swift-3058-        state.isUserEditing = false
Sources/Panels/BrowserPanelView.swift-3059-        state.suggestions = []
--
Sources/Panels/BrowserPanelView.swift-3071-
Sources/Panels/BrowserPanelView.swift-3072-    case .bufferChanged(let newValue):
Sources/Panels/BrowserPanelView.swift-3073-        state.buffer = newValue
Sources/Panels/BrowserPanelView.swift:3074:        if state.isFocused {
Sources/Panels/BrowserPanelView.swift-3075-            state.isUserEditing = (newValue != state.currentURLString)
Sources/Panels/BrowserPanelView.swift-3076-            state.selectedSuggestionIndex = 0
Sources/Panels/BrowserPanelView.swift-3077-            state.selectedSuggestionID = nil
--
Sources/Panels/BrowserPanelView.swift-3122-        state.selectedSuggestionID = state.suggestions[state.selectedSuggestionIndex].id
Sources/Panels/BrowserPanelView.swift-3123-
Sources/Panels/BrowserPanelView.swift-3124-    case .escape:
Sources/Panels/BrowserPanelView.swift:3125:        guard state.isFocused else { break }
Sources/Panels/BrowserPanelView.swift-3126-        // Chrome semantics:
Sources/Panels/BrowserPanelView.swift-3127-        // - If user input is in progress OR the popup is open: revert to the page URL and select-all.
Sources/Panels/BrowserPanelView.swift-3128-        // - Otherwise: exit omnibar focus.
--
Sources/Panels/BrowserPanelView.swift-3473-
Sources/Panels/BrowserPanelView.swift-3474-private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
Sources/Panels/BrowserPanelView.swift-3475-    `@Binding` var text: String
Sources/Panels/BrowserPanelView.swift:3476:    `@Binding` var isFocused: Bool
Sources/Panels/BrowserPanelView.swift-3477-    let inlineCompletion: OmnibarInlineCompletion?
Sources/Panels/BrowserPanelView.swift-3478-    let placeholder: String
Sources/Panels/BrowserPanelView.swift-3479-    let onTap: () -> Void
--
Sources/Panels/BrowserPanelView.swift-3521-                return pendingFocusRequest ? "focus" : "blur"
Sources/Panels/BrowserPanelView.swift-3522-            }()
Sources/Panels/BrowserPanelView.swift-3523-            var line =
Sources/Panels/BrowserPanelView.swift:3524:                "browser.focus.field event=\(event) focused=\(parent.isFocused ? 1 : 0) " +
Sources/Panels/BrowserPanelView.swift-3525-                "pending=\(pendingValue) suppressWeb=\(parent.shouldSuppressWebViewFocus() ? 1 : 0) " +
Sources/Panels/BrowserPanelView.swift-3526-                "win=\(window?.windowNumber ?? -1) fr=\(responderType) frIsField=\(responderIsField)"
Sources/Panels/BrowserPanelView.swift-3527-            if !detail.isEmpty {
--
Sources/Panels/BrowserPanelView.swift-3610-                return false
Sources/Panels/BrowserPanelView.swift-3611-            }
Sources/Panels/BrowserPanelView.swift-3612-            return browserOmnibarShouldReacquireFocusAfterEndEditing(
Sources/Panels/BrowserPanelView.swift:3613:                desiredOmnibarFocus: parent.isFocused,
Sources/Panels/BrowserPanelView.swift-3614-                nextResponderIsOtherTextField: nextResponderIsOtherTextField(window: window)
Sources/Panels/BrowserPanelView.swift-3615-            )
Sources/Panels/BrowserPanelView.swift-3616-        }
--
Sources/Panels/BrowserPanelView.swift-3619-#if DEBUG
Sources/Panels/BrowserPanelView.swift-3620-            logFocusEvent("controlTextDidBeginEditing")
Sources/Panels/BrowserPanelView.swift-3621-#endif
Sources/Panels/BrowserPanelView.swift:3622:            if !parent.isFocused {
Sources/Panels/BrowserPanelView.swift-3623-                DispatchQueue.main.async {
Sources/Panels/BrowserPanelView.swift-3624-#if DEBUG
Sources/Panels/BrowserPanelView.swift-3625-                    self.logFocusEvent("controlTextDidBeginEditing.asyncSetFocused", detail: "old=0 new=1")
Sources/Panels/BrowserPanelView.swift-3626-#endif
Sources/Panels/BrowserPanelView.swift:3627:                    self.parent.isFocused = true
Sources/Panels/BrowserPanelView.swift-3628-                }
Sources/Panels/BrowserPanelView.swift-3629-            }
Sources/Panels/BrowserPanelView.swift-3630-            attachSelectionObserverIfNeeded()
--
Sources/Panels/BrowserPanelView.swift-3640-                detail: "nextOther=\(nextOther ? 1 : 0) pointerBlur=\(pointerBlur ? 1 : 0) shouldReacquire=\(shouldReacquireFocusAfterEndEditing(window: parentField?.window) ? 1 : 0)"
Sources/Panels/BrowserPanelView.swift-3641-            )
Sources/Panels/BrowserPanelView.swift-3642-#endif
Sources/Panels/BrowserPanelView.swift:3643:            if parent.isFocused {
Sources/Panels/BrowserPanelView.swift-3644-                if shouldReacquireFocusAfterEndEditing(window: parentField?.window) {
Sources/Panels/BrowserPanelView.swift-3645-#if DEBUG
Sources/Panels/BrowserPanelView.swift-3646-                    logFocusEvent("controlTextDidEndEditing.reacquire.begin")
--
Sources/Panels/BrowserPanelView.swift-3653-#if DEBUG
Sources/Panels/BrowserPanelView.swift-3654-                        self.logFocusEvent("controlTextDidEndEditing.reacquire.tick")
Sources/Panels/BrowserPanelView.swift-3655-#endif
Sources/Panels/BrowserPanelView.swift:3656:                        guard self.parent.isFocused else { return }
Sources/Panels/BrowserPanelView.swift-3657-                        guard let field = self.parentField, let window = field.window else { return }
Sources/Panels/BrowserPanelView.swift-3658-                        guard self.shouldReacquireFocusAfterEndEditing(window: window) else {
Sources/Panels/BrowserPanelView.swift-3659-#if DEBUG
--
Sources/Panels/BrowserPanelView.swift-4001-                firstResponder === nsView ||
Sources/Panels/BrowserPanelView.swift-4002-                nsView.currentEditor() != nil ||
Sources/Panels/BrowserPanelView.swift-4003-                ((firstResponder as? NSTextView)?.delegate as? NSTextField) === nsView
Sources/Panels/BrowserPanelView.swift:4004:            if isFocused, !isFirstResponder, context.coordinator.pendingFocusRequest != true {
Sources/Panels/BrowserPanelView.swift-4005-#if DEBUG
Sources/Panels/BrowserPanelView.swift-4006-                context.coordinator.logFocusEvent(
Sources/Panels/BrowserPanelView.swift-4007-                    "updateNSView.requestFocus.begin",
Sources/Panels/BrowserPanelView.swift:4008:                    detail: "isFocused=1 isFirstResponder=0"
Sources/Panels/BrowserPanelView.swift-4009-                )
Sources/Panels/BrowserPanelView.swift-4010-#endif
Sources/Panels/BrowserPanelView.swift-4011-                // Defer to avoid triggering input method XPC during layout pass,
--
Sources/Panels/BrowserPanelView.swift-4015-                    coordinator?.pendingFocusRequest = nil
Sources/Panels/BrowserPanelView.swift-4016-                    guard let nsView, let window = nsView.window else { return }
Sources/Panels/BrowserPanelView.swift-4017-#if DEBUG
Sources/Panels/BrowserPanelView.swift:4018:                    if coordinator?.parent.isFocused != true {
Sources/Panels/BrowserPanelView.swift-4019-                        coordinator?.logFocusEvent("updateNSView.requestFocus.cancel", detail: "reason=stale_state")
Sources/Panels/BrowserPanelView.swift-4020-                        return
Sources/Panels/BrowserPanelView.swift-4021-                    }
Sources/Panels/BrowserPanelView.swift-4022-#endif
Sources/Panels/BrowserPanelView.swift:4023:                    guard coordinator?.parent.isFocused == true else { return }
Sources/Panels/BrowserPanelView.swift-4024-#if DEBUG
Sources/Panels/BrowserPanelView.swift-4025-                    coordinator?.logFocusEvent("updateNSView.requestFocus.tick")
Sources/Panels/BrowserPanelView.swift-4026-#endif
--
Sources/Panels/BrowserPanelView.swift-4034-#endif
Sources/Panels/BrowserPanelView.swift-4035-                    window.makeFirstResponder(nsView)
Sources/Panels/BrowserPanelView.swift-4036-                }
Sources/Panels/BrowserPanelView.swift:4037:            } else if !isFocused, isFirstResponder, context.coordinator.pendingFocusRequest != false {
Sources/Panels/BrowserPanelView.swift-4038-#if DEBUG
Sources/Panels/BrowserPanelView.swift-4039-                context.coordinator.logFocusEvent(
Sources/Panels/BrowserPanelView.swift-4040-                    "updateNSView.requestBlur.begin",
Sources/Panels/BrowserPanelView.swift:4041:                    detail: "isFocused=0 isFirstResponder=1"
Sources/Panels/BrowserPanelView.swift-4042-                )
Sources/Panels/BrowserPanelView.swift-4043-#endif
Sources/Panels/BrowserPanelView.swift-4044-                context.coordinator.pendingFocusRequest = false
--
Sources/Panels/BrowserPanelView.swift-4046-                    coordinator?.pendingFocusRequest = nil
Sources/Panels/BrowserPanelView.swift-4047-                    guard let nsView, let window = nsView.window else { return }
Sources/Panels/BrowserPanelView.swift-4048-#if DEBUG
Sources/Panels/BrowserPanelView.swift:4049:                    if coordinator?.parent.isFocused == true {
Sources/Panels/BrowserPanelView.swift-4050-                        coordinator?.logFocusEvent("updateNSView.requestBlur.cancel", detail: "reason=stale_state")
Sources/Panels/BrowserPanelView.swift-4051-                        return
Sources/Panels/BrowserPanelView.swift-4052-                    }
Sources/Panels/BrowserPanelView.swift-4053-#endif
Sources/Panels/BrowserPanelView.swift:4054:                    guard coordinator?.parent.isFocused == false else { return }
Sources/Panels/BrowserPanelView.swift-4055-#if DEBUG
Sources/Panels/BrowserPanelView.swift-4056-                    coordinator?.logFocusEvent("updateNSView.requestBlur.tick")
Sources/Panels/BrowserPanelView.swift-4057-#endif
--
Sources/Find/BrowserSearchOverlay.swift-23-            HStack(spacing: 4) {
Sources/Find/BrowserSearchOverlay.swift-24-                BrowserSearchTextFieldRepresentable(
Sources/Find/BrowserSearchOverlay.swift-25-                    text: $searchState.needle,
Sources/Find/BrowserSearchOverlay.swift:26:                    isFocused: $isSearchFieldFocused,
Sources/Find/BrowserSearchOverlay.swift-27-                    panelId: panelId,
Sources/Find/BrowserSearchOverlay.swift-28-                    focusRequestGeneration: focusRequestGeneration,
Sources/Find/BrowserSearchOverlay.swift-29-                    canApplyFocusRequest: canApplyFocusRequest,
--
Sources/Find/BrowserSearchOverlay.swift-197-
Sources/Find/BrowserSearchOverlay.swift-198-private struct BrowserSearchTextFieldRepresentable: NSViewRepresentable {
Sources/Find/BrowserSearchOverlay.swift-199-    `@Binding` var text: String
Sources/Find/BrowserSearchOverlay.swift:200:    `@Binding` var isFocused: Bool
Sources/Find/BrowserSearchOverlay.swift-201-    let panelId: UUID
Sources/Find/BrowserSearchOverlay.swift-202-    let focusRequestGeneration: UInt64
Sources/Find/BrowserSearchOverlay.swift-203-    let canApplyFocusRequest: (UInt64) -> Bool
--
Sources/Find/BrowserSearchOverlay.swift-240-
Sources/Find/BrowserSearchOverlay.swift-241-        func controlTextDidBeginEditing(_ obj: Notification) {
Sources/Find/BrowserSearchOverlay.swift-242-            parent.onFieldDidFocus()
Sources/Find/BrowserSearchOverlay.swift:243:            if !parent.isFocused {
Sources/Find/BrowserSearchOverlay.swift-244-                DispatchQueue.main.async {
Sources/Find/BrowserSearchOverlay.swift:245:                    self.parent.isFocused = true
Sources/Find/BrowserSearchOverlay.swift-246-                }
Sources/Find/BrowserSearchOverlay.swift-247-            }
Sources/Find/BrowserSearchOverlay.swift-248-        }
Sources/Find/BrowserSearchOverlay.swift-249-
Sources/Find/BrowserSearchOverlay.swift-250-        func controlTextDidEndEditing(_ obj: Notification) {
Sources/Find/BrowserSearchOverlay.swift:251:            if parent.isFocused {
Sources/Find/BrowserSearchOverlay.swift-252-                DispatchQueue.main.async {
Sources/Find/BrowserSearchOverlay.swift:253:                    self.parent.isFocused = false
Sources/Find/BrowserSearchOverlay.swift-254-                }
Sources/Find/BrowserSearchOverlay.swift-255-            }
Sources/Find/BrowserSearchOverlay.swift-256-        }
--
Sources/Find/BrowserSearchOverlay.swift-331-                nsView.currentEditor() != nil ||
Sources/Find/BrowserSearchOverlay.swift-332-                ((fr as? NSTextView)?.delegate as? NSTextField) === nsView
Sources/Find/BrowserSearchOverlay.swift-333-
Sources/Find/BrowserSearchOverlay.swift:334:            if isFocused,
Sources/Find/BrowserSearchOverlay.swift-335-               canApplyFocusRequest(focusRequestGeneration),
Sources/Find/BrowserSearchOverlay.swift-336-               !isFirstResponder,
Sources/Find/BrowserSearchOverlay.swift-337-               context.coordinator.pendingFocusRequest != true {
--
Sources/Find/BrowserSearchOverlay.swift-339-                DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
Sources/Find/BrowserSearchOverlay.swift-340-                    coordinator?.pendingFocusRequest = nil
Sources/Find/BrowserSearchOverlay.swift-341-                    guard let coordinator,
Sources/Find/BrowserSearchOverlay.swift:342:                          coordinator.parent.isFocused,
Sources/Find/BrowserSearchOverlay.swift-343-                          coordinator.parent.canApplyFocusRequest(coordinator.parent.focusRequestGeneration) else { return }
Sources/Find/BrowserSearchOverlay.swift-344-                    guard let nsView, let window = nsView.window else { return }
Sources/Find/BrowserSearchOverlay.swift-345-                    let fr = window.firstResponder
--
Sources/Find/SurfaceSearchOverlay.swift-36-            HStack(spacing: 4) {
Sources/Find/SurfaceSearchOverlay.swift-37-                SearchTextFieldRepresentable(
Sources/Find/SurfaceSearchOverlay.swift-38-                    text: $searchState.needle,
Sources/Find/SurfaceSearchOverlay.swift:39:                    isFocused: $isSearchFieldFocused,
Sources/Find/SurfaceSearchOverlay.swift-40-                    surfaceId: surfaceId,
Sources/Find/SurfaceSearchOverlay.swift-41-                    canApplyFocusRequest: canApplyFocusRequest,
Sources/Find/SurfaceSearchOverlay.swift-42-                    onFieldDidFocus: onFieldDidFocus,
--
Sources/Find/SurfaceSearchOverlay.swift-227-/// after window switching.
Sources/Find/SurfaceSearchOverlay.swift-228-private struct SearchTextFieldRepresentable: NSViewRepresentable {
Sources/Find/SurfaceSearchOverlay.swift-229-    `@Binding` var text: String
Sources/Find/SurfaceSearchOverlay.swift:230:    `@Binding` var isFocused: Bool
Sources/Find/SurfaceSearchOverlay.swift-231-    let surfaceId: UUID
Sources/Find/SurfaceSearchOverlay.swift-232-    let canApplyFocusRequest: () -> Bool
Sources/Find/SurfaceSearchOverlay.swift-233-    let onFieldDidFocus: () -> Void
--
Sources/Find/SurfaceSearchOverlay.swift-262-            dlog("find.nativeField.beginEditing surface=\(parent.surfaceId.uuidString.prefix(5))")
Sources/Find/SurfaceSearchOverlay.swift-263-            `#endif`
Sources/Find/SurfaceSearchOverlay.swift-264-            parent.onFieldDidFocus()
Sources/Find/SurfaceSearchOverlay.swift:265:            if !parent.isFocused {
Sources/Find/SurfaceSearchOverlay.swift-266-                DispatchQueue.main.async {
Sources/Find/SurfaceSearchOverlay.swift:267:                    self.parent.isFocused = true
Sources/Find/SurfaceSearchOverlay.swift-268-                }
Sources/Find/SurfaceSearchOverlay.swift-269-            }
Sources/Find/SurfaceSearchOverlay.swift-270-        }
--
Sources/Find/SurfaceSearchOverlay.swift-273-            `#if` DEBUG
Sources/Find/SurfaceSearchOverlay.swift-274-            dlog("find.nativeField.endEditing surface=\(parent.surfaceId.uuidString.prefix(5))")
Sources/Find/SurfaceSearchOverlay.swift-275-            `#endif`
Sources/Find/SurfaceSearchOverlay.swift:276:            if parent.isFocused {
Sources/Find/SurfaceSearchOverlay.swift-277-                DispatchQueue.main.async {
Sources/Find/SurfaceSearchOverlay.swift:278:                    self.parent.isFocused = false
Sources/Find/SurfaceSearchOverlay.swift-279-                }
Sources/Find/SurfaceSearchOverlay.swift-280-            }
Sources/Find/SurfaceSearchOverlay.swift-281-        }
--
Sources/Find/SurfaceSearchOverlay.swift-374-                nsView.currentEditor() != nil ||
Sources/Find/SurfaceSearchOverlay.swift-375-                ((fr as? NSTextView)?.delegate as? NSTextField) === nsView
Sources/Find/SurfaceSearchOverlay.swift-376-
Sources/Find/SurfaceSearchOverlay.swift:377:            if isFocused,
Sources/Find/SurfaceSearchOverlay.swift-378-               canApplyFocusRequest(),
Sources/Find/SurfaceSearchOverlay.swift-379-               !isFirstResponder,
Sources/Find/SurfaceSearchOverlay.swift-380-               context.coordinator.pendingFocusRequest != true {
--
Sources/Find/SurfaceSearchOverlay.swift-382-                DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
Sources/Find/SurfaceSearchOverlay.swift-383-                    coordinator?.pendingFocusRequest = nil
Sources/Find/SurfaceSearchOverlay.swift-384-                    guard let coordinator,
Sources/Find/SurfaceSearchOverlay.swift:385:                          coordinator.parent.isFocused,
Sources/Find/SurfaceSearchOverlay.swift-386-                          coordinator.parent.canApplyFocusRequest() else { return }
Sources/Find/SurfaceSearchOverlay.swift-387-                    guard let nsView, let window = nsView.window else { return }
Sources/Find/SurfaceSearchOverlay.swift-388-                    let fr = window.firstResponder
--
Sources/Panels/ReactGrab.swift-31-struct ReactGrabShortcutPanelSnapshot: Equatable {
Sources/Panels/ReactGrab.swift-32-    let id: UUID
Sources/Panels/ReactGrab.swift-33-    let panelType: PanelType
Sources/Panels/ReactGrab.swift:34:    let isFocused: Bool
Sources/Panels/ReactGrab.swift-35-}
Sources/Panels/ReactGrab.swift-36-
Sources/Panels/ReactGrab.swift-37-struct ReactGrabShortcutRoute: Equatable {
--
Sources/Panels/ReactGrab.swift-42-func resolveReactGrabShortcutRoute(
Sources/Panels/ReactGrab.swift-43-    panels: [ReactGrabShortcutPanelSnapshot]
Sources/Panels/ReactGrab.swift-44-) -> ReactGrabShortcutRoute? {
Sources/Panels/ReactGrab.swift:45:    guard let focusedPanel = panels.first(where: \.isFocused) else { return nil }
Sources/Panels/ReactGrab.swift-46-
Sources/Panels/ReactGrab.swift-47-    if focusedPanel.panelType == .browser {
Sources/Panels/ReactGrab.swift-48-        return ReactGrabShortcutRoute(
--
Sources/Panels/PanelContentView.swift-6-struct PanelContentView: View {
Sources/Panels/PanelContentView.swift-7-    let panel: any Panel
Sources/Panels/PanelContentView.swift-8-    let paneId: PaneID
Sources/Panels/PanelContentView.swift:9:    let isFocused: Bool
Sources/Panels/PanelContentView.swift-10-    let isSelectedInPane: Bool
Sources/Panels/PanelContentView.swift-11-    let isVisibleInUI: Bool
Sources/Panels/PanelContentView.swift-12-    let portalPriority: Int
--
Sources/Panels/PanelContentView.swift-24-                TerminalPanelView(
Sources/Panels/PanelContentView.swift-25-                    panel: terminalPanel,
Sources/Panels/PanelContentView.swift-26-                    paneId: paneId,
Sources/Panels/PanelContentView.swift:27:                    isFocused: isFocused,
Sources/Panels/PanelContentView.swift-28-                    isVisibleInUI: isVisibleInUI,
Sources/Panels/PanelContentView.swift-29-                    portalPriority: portalPriority,
Sources/Panels/PanelContentView.swift-30-                    isSplit: isSplit,
--
Sources/Panels/PanelContentView.swift-39-                BrowserPanelView(
Sources/Panels/PanelContentView.swift-40-                    panel: browserPanel,
Sources/Panels/PanelContentView.swift-41-                    paneId: paneId,
Sources/Panels/PanelContentView.swift:42:                    isFocused: isFocused,
Sources/Panels/PanelContentView.swift-43-                    isVisibleInUI: isVisibleInUI,
Sources/Panels/PanelContentView.swift-44-                    portalPriority: portalPriority,
Sources/Panels/PanelContentView.swift-45-                    onRequestPanelFocus: onRequestPanelFocus
--
Sources/Panels/PanelContentView.swift-49-            if let markdownPanel = panel as? MarkdownPanel {
Sources/Panels/PanelContentView.swift-50-                MarkdownPanelView(
Sources/Panels/PanelContentView.swift-51-                    panel: markdownPanel,
Sources/Panels/PanelContentView.swift:52:                    isFocused: isFocused,
Sources/Panels/PanelContentView.swift-53-                    isVisibleInUI: isVisibleInUI,
Sources/Panels/PanelContentView.swift-54-                    portalPriority: portalPriority,
Sources/Panels/PanelContentView.swift-55-                    onRequestPanelFocus: onRequestPanelFocus
--
Sources/Panels/MarkdownPanelView.swift-5-/// SwiftUI view that renders a MarkdownPanel's content using MarkdownUI.
Sources/Panels/MarkdownPanelView.swift-6-struct MarkdownPanelView: View {
Sources/Panels/MarkdownPanelView.swift-7-    `@ObservedObject` var panel: MarkdownPanel
Sources/Panels/MarkdownPanelView.swift:8:    let isFocused: Bool
Sources/Panels/MarkdownPanelView.swift-9-    let isVisibleInUI: Bool
Sources/Panels/MarkdownPanelView.swift-10-    let portalPriority: Int
Sources/Panels/MarkdownPanelView.swift-11-    let onRequestPanelFocus: () -> Void
--
Sources/ContentView.swift-4413-                CommandPaletteSearchFieldRepresentable(
Sources/ContentView.swift-4414-                    placeholder: commandPaletteSearchPlaceholder,
Sources/ContentView.swift-4415-                    text: $commandPaletteQuery,
Sources/ContentView.swift:4416:                    isFocused: Binding(
Sources/ContentView.swift-4417-                        get: { isCommandPaletteSearchFocused },
Sources/ContentView.swift-4418-                        set: { isCommandPaletteSearchFocused = $0 }
Sources/ContentView.swift-4419-                    ),
--
Sources/ContentView.swift-4613-                accessibilityLabel: accessibilityLabel,
Sources/ContentView.swift-4614-                accessibilityIdentifier: accessibilityIdentifier,
Sources/ContentView.swift-4615-                text: text,
Sources/ContentView.swift:4616:                isFocused: focus,
Sources/ContentView.swift-4617-                measuredHeight: measuredHeight,
Sources/ContentView.swift-4618-                maxHeight: maxHeight,
Sources/ContentView.swift-4619-                onSubmit: onSubmit,
--
Sources/ContentView.swift-4806-    private struct CommandPaletteSearchFieldRepresentable: NSViewRepresentable {
Sources/ContentView.swift-4807-        let placeholder: String
Sources/ContentView.swift-4808-        `@Binding` var text: String
Sources/ContentView.swift:4809:        `@Binding` var isFocused: Bool
Sources/ContentView.swift-4810-        let onSubmit: () -> Void
Sources/ContentView.swift-4811-        let onEscape: () -> Void
Sources/ContentView.swift-4812-        let onMoveSelection: (Int) -> Void
--
Sources/ContentView.swift-4838-                   let editor = field.currentEditor() as? NSTextView {
Sources/ContentView.swift-4839-                    attachEditorTextDidChangeObserverIfNeeded(editor)
Sources/ContentView.swift-4840-                }
Sources/ContentView.swift:4841:                if !parent.isFocused {
Sources/ContentView.swift-4842-                    DispatchQueue.main.async {
Sources/ContentView.swift:4843:                        self.parent.isFocused = true
Sources/ContentView.swift-4844-                    }
Sources/ContentView.swift-4845-                }
Sources/ContentView.swift-4846-            }
--
Sources/ContentView.swift-4976-                nsView.currentEditor() != nil ||
Sources/ContentView.swift-4977-                ((firstResponder as? NSTextView)?.delegate as? NSTextField) === nsView
Sources/ContentView.swift-4978-
Sources/ContentView.swift:4979:            if isFocused, !isFirstResponder, context.coordinator.pendingFocusRequest != true {
Sources/ContentView.swift-4980-                context.coordinator.pendingFocusRequest = true
Sources/ContentView.swift-4981-                DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
Sources/ContentView.swift-4982-                    coordinator?.pendingFocusRequest = nil
Sources/ContentView.swift:4983:                    guard let coordinator, coordinator.parent.isFocused else { return }
Sources/ContentView.swift-4984-                    guard let nsView, let window = nsView.window else { return }
Sources/ContentView.swift-4985-                    let firstResponder = window.firstResponder
Sources/ContentView.swift-4986-                    let alreadyFocused =
--
Sources/ContentView.swift-5345-        let accessibilityLabel: String
Sources/ContentView.swift-5346-        let accessibilityIdentifier: String
Sources/ContentView.swift-5347-        `@Binding` var text: String
Sources/ContentView.swift:5348:        `@Binding` var isFocused: Bool
Sources/ContentView.swift-5349-        `@Binding` var measuredHeight: CGFloat
Sources/ContentView.swift-5350-        let maxHeight: CGFloat
Sources/ContentView.swift-5351-        let onSubmit: (String) -> Void
--
Sources/ContentView.swift-5363-            func textDidBeginEditing(_ notification: Notification) {
Sources/ContentView.swift-5364-#if DEBUG
Sources/ContentView.swift-5365-                dlog(
Sources/ContentView.swift:5366:                    "palette.wsDescription.editor.beginEditing focus=\(parent.isFocused ? 1 : 0) " +
Sources/ContentView.swift-5367-                    "responder=\(debugCommandPaletteResponderSummary(notification.object as? NSResponder))"
Sources/ContentView.swift-5368-                )
Sources/ContentView.swift-5369-#endif
Sources/ContentView.swift:5370:                if !parent.isFocused {
Sources/ContentView.swift-5371-                    DispatchQueue.main.async {
Sources/ContentView.swift:5372:                        self.parent.isFocused = true
Sources/ContentView.swift-5373-                    }
Sources/ContentView.swift-5374-                }
Sources/ContentView.swift-5375-            }
--
Sources/ContentView.swift-5394-            func handleDidBecomeFirstResponder() {
Sources/ContentView.swift-5395-#if DEBUG
Sources/ContentView.swift-5396-                dlog(
Sources/ContentView.swift:5397:                    "palette.wsDescription.editor.didBecomeFirstResponder focus=\(parent.isFocused ? 1 : 0)"
Sources/ContentView.swift-5398-                )
Sources/ContentView.swift-5399-#endif
Sources/ContentView.swift:5400:                if !parent.isFocused {
Sources/ContentView.swift:5401:                    parent.isFocused = true
Sources/ContentView.swift-5402-                }
Sources/ContentView.swift-5403-            }
Sources/ContentView.swift-5404-
--
Sources/ContentView.swift-5489-            view.refreshMetrics()
Sources/ContentView.swift-5490-#if DEBUG
Sources/ContentView.swift-5491-            dlog(
Sources/ContentView.swift:5492:                "palette.wsDescription.editor.make focus=\(isFocused ? 1 : 0) " +
Sources/ContentView.swift-5493-                "textLen=\((text as NSString).length) " +
Sources/ContentView.swift-5494-                "height=\(String(format: "%.1f", measuredHeight))"
Sources/ContentView.swift-5495-            )
--
Sources/ContentView.swift-5517-
Sources/ContentView.swift-5518-            guard let window = nsView.window else {
Sources/ContentView.swift-5519-#if DEBUG
Sources/ContentView.swift:5520:                if isFocused {
Sources/ContentView.swift-5521-                    dlog(
Sources/ContentView.swift-5522-                        "palette.wsDescription.editor.update waitingForWindow focus=1 " +
Sources/ContentView.swift-5523-                        "pending=\(context.coordinator.pendingFocusRequest ? 1 : 0)"
--
Sources/ContentView.swift-5528-            }
Sources/ContentView.swift-5529-            let isFirstResponder = window.firstResponder === nsView.textView
Sources/ContentView.swift-5530-#if DEBUG
Sources/ContentView.swift:5531:            if isFocused || context.coordinator.pendingFocusRequest {
Sources/ContentView.swift-5532-                dlog(
Sources/ContentView.swift:5533:                    "palette.wsDescription.editor.update focus=\(isFocused ? 1 : 0) " +
Sources/ContentView.swift-5534-                    "isFirstResponder=\(isFirstResponder ? 1 : 0) " +
Sources/ContentView.swift-5535-                    "pending=\(context.coordinator.pendingFocusRequest ? 1 : 0) " +
Sources/ContentView.swift-5536-                    "window={\(debugCommandPaletteWindowSummary(window))} " +
--
Sources/ContentView.swift-5538-                )
Sources/ContentView.swift-5539-            }
Sources/ContentView.swift-5540-#endif
Sources/ContentView.swift:5541:            if isFocused, !isFirstResponder, !context.coordinator.pendingFocusRequest {
Sources/ContentView.swift-5542-                context.coordinator.pendingFocusRequest = true
Sources/ContentView.swift-5543-#if DEBUG
Sources/ContentView.swift-5544-                dlog(
--
Sources/ContentView.swift-5549-                DispatchQueue.main.async { [weak nsView, weak coordinator = context.coordinator] in
Sources/ContentView.swift-5550-                    guard let coordinator else { return }
Sources/ContentView.swift-5551-                    coordinator.pendingFocusRequest = false
Sources/ContentView.swift:5552:                    guard coordinator.parent.isFocused, let nsView else { return }
Sources/ContentView.swift-5553-                    nsView.focusIfNeeded()
Sources/ContentView.swift-5554-                }
Sources/ContentView.swift-5555-            }
--
Sources/ContentView.swift-10462-                TerminalPanelView(
Sources/ContentView.swift-10463-                    panel: panel,
Sources/ContentView.swift-10464-                    paneId: paneId,
Sources/ContentView.swift:10465:                    isFocused: isVisibleInUI,
Sources/ContentView.swift-10466-                    isVisibleInUI: isVisibleInUI,
Sources/ContentView.swift-10467-                    portalPriority: 4,
Sources/ContentView.swift-10468-                    isSplit: false,

Script executed:

# Find and inspect GhosttyTerminalView to see how isActive is used
rg -n "isActive" Sources/GhosttyTerminalView.swift -A 2 | head -100

Repository: manaflow-ai/cmux


Repository: manaflow-ai/cmux
Exit code: 0

stdout:

1833:            ghostty_app_set_focus(app, NSApp.isActive)
1834-        }
1835-
--
8371:            appIsActive: NSApp.isActive,
8372-            windowIsKey: window.isKeyWindow,
8373-            alreadyFirstResponder: alreadyFirstResponder,
--
8961:    private var isActive = true
8962-    private var lastFocusRefreshAt: CFTimeInterval = 0
8963-    private var lastRequestedPortalOcclusionVisible: Bool?
--
9419:            guard self.isActive || self.surfaceView.desiredFocus || self.isSurfaceViewFirstResponder() else {
9420-                return
9421-            }
--
10506:        isActive
10507-    }
10508-
--
10515:        let wasActive = isActive
10516:        isActive = active
10517-#if DEBUG
10518-        if wasActive != active {
--
10569:            "surface=\(surface) transition=\(transition) active=\(isActive ? 1 : 0) " +
10570-            "visibleFlag=\(surfaceView.isVisibleInUI ? 1 : 0) hostHidden=\(hostHidden) surfaceHidden=\(surfaceHidden) " +
10571-            "hiddenHierarchy=\(hiddenInHierarchy) inWindow=\(inWindow) hasSuperview=\(hasSuperview) " +
--
10755:        guard isActive else { return }
10756-        guard let window else { return }
10757-        guard surfaceView.isVisibleInUI else {
--
10959:              isActive,
10960-              let window,
10961-              window.isKeyWindow,
--
10983:        guard isActive else { return }
10984-        guard surfaceView.isVisibleInUI else { return }
10985-        guard !isHiddenForFocus, hasUsablePortalGeometry else {
--
11321:        let isActive: Bool
11322-        let desiredFocus: Bool
11323-        let isFirstResponder: Bool
--
11339:        let appIsActive = NSApp.isActive
11340-        let fr = window?.firstResponder as? NSView
11341-        let isFirstResponder = fr == surfaceView || (fr?.isDescendant(of: surfaceView) ?? false)
--
11355:            isActive: isActive,
11356-            desiredFocus: surfaceView.desiredFocus,
11357-            isFirstResponder: isFirstResponder
--
12323:    var isActive: Bool = true
12324-    var isVisibleInUI: Bool = true
12325-    var portalZPriority: Int = 0
--
12480:            previousDesiredIsActive != isActive ||
12481-            previousDesiredIsVisibleInUI != isVisibleInUI ||
12482-            previousDesiredPortalZPriority != portalZPriority
12483:        coordinator.desiredIsActive = isActive
12484-        coordinator.desiredIsVisibleInUI = isVisibleInUI
12485-        coordinator.desiredShowsUnreadNotificationRing = showsUnreadNotificationRing
--
12495:                    "active=\(isActive ? 1 : 0) z=\(portalZPriority) " +
12496-                    "hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " +
12497-                    "hostedSuperview=\(hostedView.superview != nil ? 1 : 0)"
--
12502:                    "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority) " +
12503-                    "hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " +
12504-                    "hostedSuperview=\(hostedView.superview != nil ? 1 : 0)"
--
12552:                "active=\(isActive ? 1 : 0) visible=\(isVisibleInUI ? 1 : 0) " +
12553-                "inWindow=\(hostedView.window != nil ? 1 : 0)"
12554-            )
--
12560:                "requested=\(String(describing: paneDropZone!)) visible=0 active=\(isActive ? 1 : 0)"
12561-            )
12562-        }
--
12713:            hostedView.setActive(isActive)
12714-        } else {
12715-            // Preserve portal entry visibility while a stale host is still receiving SwiftUI updates.
--
12724:                    "visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)"
12725-                )
12726-            }

Script executed:

# Search for first-responder or keyboard focus side effects triggered by isActive
rg -n "firstResponder|makeFirstResponder" Sources/GhosttyTerminalView.swift -B 2 -A 2

Repository: manaflow-ai/cmux


Repository: manaflow-ai/cmux
Exit code: 0

stdout:

5642-        guard hitTest(location) == self else { return event }
5643-
5644:        Self.focusLog("localEventScrollWheel: window=\(ObjectIdentifier(window)) firstResponder=\(String(describing: window.firstResponder))")
5645-        return event
5646-    }
--
6418-            if suppressingReparentFocus {
6419-#if DEBUG
6420:                dlog("focus.firstResponder SUPPRESSED (reparent) surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
6421-#endif
6422-                return result
--
6434-#if DEBUG
6435-                dlog(
6436:                    "focus.firstResponder SUPPRESSED (hidden_or_tiny) surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
6437-                    "frame=\(String(format: "%.1fx%.1f", bounds.width, bounds.height)) hidden=\(hiddenInHierarchy ? 1 : 0)"
6438-                )
--
6445-            Self.focusLog("becomeFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))")
6446-#if DEBUG
6447:            dlog("focus.firstResponder surface=\(terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
6448-            if let terminalSurface {
6449-                AppDelegate.shared?.recordJumpUnreadFocusIfExpected(
--
6569-#endif
6570-        guard event.type == .keyDown else { return false }
6571:        guard let fr = window?.firstResponder as? NSView,
6572-              fr === self || fr.isDescendant(of: self) else { return false }
6573-        guard let surface = ensureSurfaceReadyForInput() else { return false }
--
7552-        // repairs workspace/pane active state before key routing runs.
7553-        requestPointerFocusRecovery()
7554:        window?.makeFirstResponder(self)
7555-        if let terminalSurface {
7556-            AppDelegate.shared?.tabManager?.dismissNotificationOnDirectInteraction(
--
8043-        let mods = GHOSTTY_MODS_NONE
8044-
8045:        window?.makeFirstResponder(self)
8046-        ghostty_surface_mouse_pos(surface, start.x, bounds.height - start.y, mods)
8047-        _ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods)
--
8137-        let mods = modsFromFlags(flags)
8138-
8139:        window?.makeFirstResponder(self)
8140-        ghostty_surface_mouse_pos(surface, clampedPoint.x, bounds.height - clampedPoint.y, mods)
8141-        let pressHandled = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods)
--
8169-
8170-        requestPointerFocusRecovery()
8171:        window?.makeFirstResponder(self)
8172-        let point = convert(event.locationInWindow, from: nil)
8173-        ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
--
8191-        }
8192-        requestPointerFocusRecovery()
8193:        window?.makeFirstResponder(self)
8194-        guard let surface = surface else { return }
8195-        let point = convert(event.locationInWindow, from: nil)
--
8213-        }
8214-
8215:        window?.makeFirstResponder(self)
8216-        let point = convert(event.locationInWindow, from: nil)
8217-        ghostty_surface_mouse_pos(surface, point.x, bounds.height - point.y, modsFromEvent(event))
--
8365-    private func maybeRequestFirstResponderForMouseFocus() {
8366-        guard let window else { return }
8367:        let alreadyFirstResponder = window.firstResponder === self
8368-        let shouldRequest = Self.shouldRequestFirstResponderForMouseFocus(
8369-            focusFollowsMouseEnabled: GhosttyApp.shared.focusFollowsMouseEnabled(),
--
8377-        )
8378-        guard shouldRequest else { return }
8379:        window.makeFirstResponder(self)
8380-    }
8381-
--
8406-        guard let surface = surface else { return }
8407-        lastScrollEventTime = CACurrentMediaTime()
8408:        Self.focusLog("scrollWheel: surface=\(terminalSurface?.id.uuidString ?? "nil") firstResponder=\(String(describing: window?.firstResponder))")
8409-        var x = event.scrollingDeltaX
8410-        var y = event.scrollingDeltaY
--
8842-        // which causes pane-content drift instead of terminal scrollback movement.
8843-        GhosttyNSView.focusLog("GhosttyScrollView.scrollWheel: surface scroll")
8844:        if window?.firstResponder !== surfaceView {
8845:            window?.makeFirstResponder(surfaceView)
8846-        }
8847-        surfaceView.scrollWheel(with: event)
--
9770-            let searchActive = self.surfaceView.terminalSurface?.searchState != nil
9771-#if DEBUG
9772:            dlog("find.window.didBecomeKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) focusTarget=\(self.searchFocusTarget) firstResponder=\(String(describing: self.window?.firstResponder))")
9773-#endif
9774-            self.scheduleAutomaticFirstResponderApply(reason: "didBecomeKey")
--
9783-            // Losing key window does not always trigger first-responder resignation, so force
9784-            // the focused terminal view to yield responder to keep Ghostty cursor/focus state in sync.
9785:            if let fr = window.firstResponder as? NSView,
9786-               fr === self.surfaceView || fr.isDescendant(of: self.surfaceView) {
9787-#if DEBUG
9788-                dlog("find.window.didResignKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) resigningFirstResponder")
9789-#endif
9790:                window.makeFirstResponder(nil)
9791-            } else {
9792-#if DEBUG
9793:                dlog("find.window.didResignKey surface=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") searchActive=\(searchActive) firstResponder=\(String(describing: window.firstResponder)) (not terminal, skipping)")
9794-#endif
9795-            }
--
10101-        }
10102-
10103:        let firstResponder = window.firstResponder
10104:        let alreadyFocused = mountedSearchFieldOwnsResponder(firstResponder, field: field)
10105-        guard !alreadyFocused else { return }
10106-
10107-        surfaceView.terminalSurface?.setFocus(false)
10108:        let result = window.makeFirstResponder(field)
10109-#if DEBUG
10110-        dlog(
10111-            "find.mountedFieldFocus surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
10112-            "result=\(result ? 1 : 0) attemptsRemaining=\(attemptsRemaining) " +
10113:            "firstResponder=\(String(describing: window.firstResponder))"
10114-        )
10115-#endif
--
10484-        if !visible {
10485-            // If we were focused, yield first responder.
10486:            if let window, let fr = window.firstResponder as? NSView,
10487-               fr === surfaceView || fr.isDescendant(of: surfaceView) {
10488:                window.makeFirstResponder(nil)
10489-            }
10490-        } else {
--
10543-
10544-    private func debugFirstResponderLabel() -> String {
10545:        guard let window, let firstResponder = window.firstResponder else { return "nil" }
10546:        if let view = firstResponder as? NSView {
10547-            if view === surfaceView {
10548-                return "surfaceView"
--
10553-            return String(describing: type(of: view))
10554-        }
10555:        return String(describing: type(of: firstResponder))
10556-    }
10557-
--
10570-            "visibleFlag=\(surfaceView.isVisibleInUI ? 1 : 0) hostHidden=\(hostHidden) surfaceHidden=\(surfaceHidden) " +
10571-            "hiddenHierarchy=\(hiddenInHierarchy) inWindow=\(inWindow) hasSuperview=\(hasSuperview) " +
10572:            "bounds=\(boundsText) frame=\(frameText) firstResponder=\(responder)"
10573-    }
10574-#endif
--
10589-            guard let window = self.window else { return }
10590-#if DEBUG
10591:            let before = String(describing: window.firstResponder)
10592-#endif
10593-            if let previous, previous !== self {
10594-                _ = previous.surfaceView.resignFirstResponder()
10595-            }
10596:            let result = window.makeFirstResponder(self.surfaceView)
10597-#if DEBUG
10598-            dlog(
10599-                "find.moveFocus.apply to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
10600:                "result=\(result ? 1 : 0) before=\(before) after=\(String(describing: window.firstResponder))"
10601-            )
10602-#endif
--
10698-    ) -> Bool {
10699-        guard let window else { return false }
10700:        window.makeFirstResponder(surfaceView)
10701-
10702-        let timestamp = ProcessInfo.processInfo.systemUptime
--
10811-                "focus.ensure.search surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
10812-                "tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
10813:                "firstResponder=\(String(describing: window.firstResponder))"
10814-            )
10815-#endif
--
10818-        }
10819-
10820:        if let fr = window.firstResponder as? NSView,
10821-           fr === surfaceView || fr.isDescendant(of: surfaceView) {
10822-            reassertTerminalSurfaceFocus(reason: "ensureFocus.alreadyFirstResponder")
--
10836-            window.makeKeyAndOrderFront(nil)
10837-        }
10838:        let result = window.makeFirstResponder(surfaceView)
10839-#if DEBUG
10840-        dlog(
10841-            "focus.ensure.apply surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
10842-            "tab=\(tabId.uuidString.prefix(5)) panel=\(surfaceId.uuidString.prefix(5)) " +
10843:            "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
10844-        )
10845-#endif
--
10910-            dlog(
10911-                "focus.reparent.resume.restoreFirstResponder surface=\(surfaceShort) " +
10912:                "firstResponder=\(String(describing: window.firstResponder))"
10913-            )
10914-#endif
10915:            guard window.makeFirstResponder(surfaceView), isSurfaceViewFirstResponder() else { return }
10916-        }
10917-#if DEBUG
10918:        dlog("focus.reparent.resume surface=\(surfaceShort) firstResponder=\(String(describing: window.firstResponder))")
10919-#endif
10920-        reassertTerminalSurfaceFocus(reason: "clearSuppressReparentFocus")
--
10925-    /// first responder transiently while focus is being applied.
10926-    func isSurfaceViewFirstResponder() -> Bool {
10927:        guard let window, let fr = window.firstResponder as? NSView else { return false }
10928-        return fr === surfaceView || fr.isDescendant(of: surfaceView)
10929-    }
--
11012-            return
11013-        }
11014:        if let fr = window.firstResponder as? NSView,
11015-           fr === surfaceView || fr.isDescendant(of: surfaceView) {
11016-            reassertTerminalSurfaceFocus(reason: "applyFirstResponder.alreadyFirstResponder")
--
11018-        }
11019-        // Don't steal focus from a search overlay on another surface in this window.
11020:        if let fr = window.firstResponder, isSearchOverlayOrDescendant(fr) {
11021-#if DEBUG
11022-            dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=searchOverlayFocused")
--
11029-        // focus instantly because applyFirstResponderIfNeeded runs on a deferred async tick and
11030-        // sees the field editor as a "foreign" responder to clobber.
11031:        if window.firstResponder is NSText {
11032-#if DEBUG
11033-            dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=textEditorFocused")
--
11036-        }
11037-#if DEBUG
11038:        dlog("find.applyFirstResponder APPLY surface=\(surfaceShort) prevFirstResponder=\(String(describing: window.firstResponder))")
11039-#endif
11040:        window.makeFirstResponder(surfaceView)
11041-        if isSurfaceViewFirstResponder() {
11042-            reassertTerminalSurfaceFocus(reason: "applyFirstResponder.afterMakeFirstResponder")
--
11050-        switch searchFocusTarget {
11051-        case .searchField:
11052:            if let firstResponder = window.firstResponder,
11053:               isCurrentSurfaceSearchFieldResponder(firstResponder) {
11054-                surfaceView.terminalSurface?.setFocus(false)
11055-#if DEBUG
11056-                dlog(
11057-                    "find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " +
11058:                    "reason=alreadyFocused firstResponder=\(String(describing: firstResponder))"
11059-                )
11060-#endif
11061-                return
11062-            }
11063:            if let firstResponder = window.firstResponder,
11064:               isSearchOverlayOrDescendant(firstResponder),
11065:               !isCurrentSurfaceSearchResponder(firstResponder) {
11066-                surfaceView.terminalSurface?.setFocus(false)
11067-#if DEBUG
11068-                dlog(
11069-                    "find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " +
11070:                    "reason=foreignSearchResponder firstResponder=\(String(describing: firstResponder))"
11071-                )
11072-#endif
--
11080-            surfaceView.terminalSurface?.setFocus(false)
11081-            // Post notification — SearchTextFieldRepresentable's Coordinator
11082:            // observes it and calls makeFirstResponder on the native NSTextField.
11083-            if let terminalSurface = surfaceView.terminalSurface {
11084-                NotificationCenter.default.post(name: .ghosttySearchFocus, object: terminalSurface)
--
11087-            dlog(
11088-                "find.restoreSearchFocus surface=\(surfaceShort) target=searchField " +
11089:                "via=notification firstResponder=\(String(describing: window.firstResponder))"
11090-            )
11091-#endif
11092-        case .terminal:
11093:            let result = window.makeFirstResponder(surfaceView)
11094-#if DEBUG
11095-            dlog(
11096-                "find.restoreSearchFocus surface=\(surfaceShort) target=terminal " +
11097:                "result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
11098-            )
11099-#endif
--
11113-        }
11114-
11115:        let firstResponder = window.firstResponder
11116:        let alreadyFocused = mountedSearchFieldOwnsResponder(firstResponder, field: field)
11117-
11118-        surfaceView.terminalSurface?.setFocus(false)
--
11122-            dlog(
11123-                "find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " +
11124:                "reason=mountedFieldAlreadyFocused firstResponder=\(String(describing: firstResponder))"
11125-            )
11126-        }
--
11128-        guard !alreadyFocused else { return true }
11129-
11130:        let result = window.makeFirstResponder(field)
11131:        let ownsField = mountedSearchFieldOwnsResponder(window.firstResponder, field: field)
11132-
11133-#if DEBUG
11134-        dlog(
11135-            "find.restoreSearchFocus surface=\(surfaceShort) target=searchField " +
11136:            "via=mountedField result=\(result ? 1 : 0) firstResponder=\(String(describing: window.firstResponder))"
11137-        )
11138-#endif
--
11143-    func capturePanelFocusIntent(in window: NSWindow?) -> TerminalPanelFocusIntent {
11144-        if surfaceView.terminalSurface?.searchState != nil {
11145:            if let firstResponder = window?.firstResponder as? NSView,
11146:               (firstResponder === surfaceView || firstResponder.isDescendant(of: surfaceView)) {
11147-                return .surface
11148-            }
11149:            if let firstResponder = window?.firstResponder,
11150:               isCurrentSurfaceSearchResponder(firstResponder) {
11151-                return .findField
11152-            }
--
11217-            dlog(
11218-                "find.restorePanelFocusIntent surface=\(terminalSurface.id.uuidString.prefix(5)) " +
11219:                "target=searchField firstResponder=\(String(describing: window?.firstResponder))"
11220-            )
11221-#endif
--
11238-    `@discardableResult`
11239-    func yieldPanelFocusIntent(_ intent: TerminalPanelFocusIntent, in window: NSWindow) -> Bool {
11240:        guard let firstResponder = window.firstResponder,
11241:              ownedPanelFocusIntent(for: firstResponder) == intent else {
11242-            return false
11243-        }
--
11256-    private func resignOwnedFirstResponderIfNeeded(reason: String) {
11257-        guard let window,
11258:              let firstResponder = window.firstResponder else { return }
11259-
11260-        let ownsSurfaceResponder: Bool = {
11261:            guard let view = firstResponder as? NSView else { return false }
11262-            return view === surfaceView || view.isDescendant(of: surfaceView)
11263-        }()
11264-
11265:        guard ownsSurfaceResponder || isCurrentSurfaceSearchResponder(firstResponder) else { return }
11266-
11267-#if DEBUG
11268-        dlog(
11269-            "focus.surface.resign surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
11270:            "reason=\(reason) firstResponder=\(String(describing: firstResponder))"
11271-        )
11272-#endif
11273:        window.makeFirstResponder(nil)
11274-    }
11275-
11276-    /// Check if a responder is inside a search overlay hosting view.
11277-    /// Handles the AppKit field-editor case: when an NSTextField is being edited,
11278:    /// window.firstResponder is the shared NSTextView field editor, not the text field.
11279-    private func isSearchOverlayOrDescendant(_ responder: NSResponder) -> Bool {
11280-        guard let view = resolvedKeyboardFocusOwnerView(for: responder) else { return false }
--
11338-        let windowOcclusionVisible = (window?.occlusionState.contains(.visible) ?? false) || (window?.isKeyWindow ?? false)
11339-        let appIsActive = NSApp.isActive
11340:        let fr = window?.firstResponder as? NSView
11341-        let isFirstResponder = fr == surfaceView || (fr?.isDescendant(of: surfaceView) ?? false)
11342-        return DebugRenderStats(

Script executed:

# Verify if sidebar terminal surface.setFocus() is the explicit focus intent or if isActive causes implicit focus
rg -n "\.setFocus" Sources/ContentView.swift -B 3 -A 1 | grep -A 4 "10470"

Repository: manaflow-ai/cmux


Repository: manaflow-ai/cmux
Exit code: 0

stdout:

10470-                    hasUnreadNotification: false,
10471-                    onFocus: {
10472:                        panel.surface.setFocus(true)
10473-                        panel.surface.requestBackgroundSurfaceStartIfNeeded()

Sidebar terminal is always "focused" while visible — risk of focus stealing.

isFocused: isVisibleInUI combined with the call-site passing isVisibleInUI: true means the sidebar terminal reports itself focused any time the left-sidebar is in .terminal mode with a selected workspace. This propagates to GhosttyTerminalView, which calls hostedView.setActive(isActive) on the portal layer, enabling keyboard routing and first-responder acquisition for a surface that isn't actually focused. Depending on interaction patterns (window switching, divider dragging, workspace changes), this can:

  • Allow the sidebar terminal to compete for keyboard first-responder with the main workspace's active panel.
  • Disrupt focus routing when two terminals (sidebar + main) both signal isActive=true.

isFocused should reflect whether the sidebar terminal actually holds focus (determined by user interaction or the onFocus callback), not merely visibility. Consider tracking a local @State private var hasFocus = false that is flipped by the onFocus closure and cleared on onDisappear or when another surface becomes first responder.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 10459 - 10476, The sidebar terminal
currently uses isFocused: isVisibleInUI which makes visibility equal focus;
change this to track real focus state by adding a local `@State` private var
hasFocus = false and pass isFocused: hasFocus to TerminalPanelView (instead of
isVisibleInUI), set hasFocus = true inside the existing onFocus closure (which
already calls panel.surface.setFocus and requestBackgroundSurfaceStartIfNeeded),
and clear hasFocus (set to false) in an onDisappear handler and whenever the
workspace's actual focusedPaneId changes away from paneId (or when you receive a
first-responder change) so the GhosttyTerminalView/portal only activates when
the sidebar truly has focus.

Comment thread Sources/ContentView.swift
Comment on lines +10459 to +10513
var body: some View {
Group {
if let panel, let paneId = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first {
TerminalPanelView(
panel: panel,
paneId: paneId,
isFocused: isVisibleInUI,
isVisibleInUI: isVisibleInUI,
portalPriority: 4,
isSplit: false,
appearance: PanelAppearance.fromConfig(config),
hasUnreadNotification: false,
onFocus: {
panel.surface.setFocus(true)
panel.surface.requestBackgroundSurfaceStartIfNeeded()
},
onTriggerFlash: {}
)
} else {
Text(String(localized: "sidebar.terminal.noWorkspace", defaultValue: "No workspace selected"))
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
ensurePanel()
}
.onChange(of: workspace.id) { _ in
panel = nil
ensurePanel()
}
.onChange(of: configuration) { _ in
ensurePanel()
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in
GhosttyConfig.invalidateLoadCache()
config = GhosttyConfig.load()
}
.onDisappear {
panel?.hostedView.setVisibleInUI(false)
panel?.hostedView.setActive(false)
}
}

private func ensurePanel() {
let nextPanel = workspace.sidebarTerminalPanel(
for: placement,
configuration: configuration,
preferredWorkingDirectory: preferredWorkingDirectory
)
guard panel?.id != nextPanel.id else { return }
panel = nextPanel
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how paneId flows through TerminalPanelView and where it's used for
# routing (focus, close, bonsplit lookups).
rg -nP --type=swift -C4 '\bpaneId\b' -g 'Sources/ContentView.swift' | head -200
ast-grep --pattern $'struct TerminalPanelView: View {
  $$$
  let paneId: $_
  $$$
}'
# Confirm sidebar-terminal panels are actually kept out of bonsplit allPaneIds.
rg -nP --type=swift -C3 '\b(sidebarTerminalPanelIds|isSidebarTerminalPanel)\b'

Repository: manaflow-ai/cmux

Length of output: 12091


🏁 Script executed:

#!/bin/bash
# Find TerminalPanelView structure and how paneId is used
ast-grep --pattern $'struct TerminalPanelView: View {
  $$$
}'
rg -nP --type=swift 'struct TerminalPanelView' -A 30 | head -100

Repository: manaflow-ai/cmux

Length of output: 2775


🏁 Script executed:

#!/bin/bash
# Search for how paneId is used inside TerminalPanelView
rg -nP --type=swift 'paneId' Sources/Panels/TerminalPanelView.swift | head -50

Repository: manaflow-ai/cmux

Length of output: 116


🏁 Script executed:

#!/bin/bash
# Check if paneId is passed to routing/focus operations
rg -nP --type=swift '\.setFocus\(|\.close\(|bonsplitController\.(focusPane|closePane|movePanel)' \
  Sources/Panels/TerminalPanelView.swift | head -50

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

#!/bin/bash
# Look for how sidebar terminal panels are actually handled - is there special handling?
rg -nP --type=swift -C5 'sidebarTerminalPanel\(' Sources/ContentView.swift

Repository: manaflow-ai/cmux

Length of output: 479


🏁 Script executed:

#!/bin/bash
# Check how GhosttyTerminalView uses the paneId parameter
rg -nP --type=swift 'paneId' Sources/GhosttyTerminalView.swift | head -80

Repository: manaflow-ai/cmux

Length of output: 2078


🏁 Script executed:

#!/bin/bash
# Check for any routing calls that use paneId in the panel/terminal context
rg -nP --type=swift 'bonsplitController\.' Sources/Panels/TerminalPanelView.swift
rg -nP --type=swift 'bonsplitController\.' Sources/GhosttyTerminalView.swift | head -50

Repository: manaflow-ai/cmux

Length of output: 819


🏁 Script executed:

#!/bin/bash
# Look for any methods that take paneId as routing parameter
rg -nP --type=swift 'func.*paneId.*\{' -A 5 Sources/Workspace.swift | head -100

Repository: manaflow-ai/cmux

Length of output: 4857


🏁 Script executed:

#!/bin/bash
# Search for where paneId from TerminalPanelView/GhosttyTerminalView is used for routing
rg -nP --type=swift 'paneId.*forPanelId|panelIdFromSurfaceId' -B 2 -A 2 | head -100

Repository: manaflow-ai/cmux

Length of output: 1883


🏁 Script executed:

#!/bin/bash
# Check if paneId from sidebar terminal ever gets used to query bonsplitController
rg -nP --type=swift 'bonsplitController\.tabs\(inPane|bonsplitController\.selectedTab' Sources/GhosttyTerminalView.swift

Repository: manaflow-ai/cmux

Length of output: 1883


🏁 Script executed:

#!/bin/bash
# Look at the portal host leasing logic - does it depend on paneId correctness?
rg -nP --type=swift 'activePortalHostLease' Sources/GhosttyTerminalView.swift -B 3 -A 3 | head -80

Repository: manaflow-ai/cmux

Length of output: 1883


🏁 Script executed:

#!/bin/bash
# Check if sidebar terminal panels work correctly - any special bypass logic?
rg -nP --type=swift -C 5 'isSidebarTerminalPanel.*paneId|paneId.*isSidebarTerminalPanel'

Repository: manaflow-ai/cmux

Length of output: 1883


Sidebar terminals should not use arbitrary Bonsplit paneIds.

Sidebar terminals are excluded from serialized layout and Bonsplit surface counts (confirmed by isSidebarTerminalPanel filtering in Workspace.swift), yet the code assigns an arbitrary bonsplit paneId from focusedPaneId ?? allPaneIds.first. While GhosttyTerminalView has defensive guards to skip bonsplit lookups for sidebar panels, the portal host leasing logic (GhosttyTerminalView lines 3738–4088) still tracks this paneId for host assignment. If the arbitrary pane is closed or modified, the sidebar terminal's portal state could be affected.

Sidebar terminals already have a dedicated stable identifier (sidebarTerminalPanelIds[placement]). Instead of borrowing a bonsplit paneId, create a dedicated sidebar paneId (e.g., a special marker or derived from placement) to properly isolate their lifecycle.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 10459 - 10513, The code currently
assigns an arbitrary bonsplit paneId in the view's if-let (using
workspace.bonsplitController.focusedPaneId ??
workspace.bonsplitController.allPaneIds.first) which leaks a Bonsplit-managed id
into the sidebar terminal's portal-hosting lifecycle; instead, use the
sidebar-specific stable id already provided by Workspace
(sidebarTerminalPanelIds[placement] or a small accessor like
sidebarTerminalPanelId(for: placement)) and pass that into TerminalPanelView as
paneId so the sidebar terminal uses a dedicated, stable paneId that won't be
tracked/modified by Bonsplit host assignment; update the conditional and any
references to paneId in this view (and ensurePanel logic if it derives pane ids)
to use the sidebar-specific id rather than bonsplitController values.

Comment thread Sources/ContentView.swift
Comment on lines +10485 to +10502
.onAppear {
ensurePanel()
}
.onChange(of: workspace.id) { _ in
panel = nil
ensurePanel()
}
.onChange(of: configuration) { _ in
ensurePanel()
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in
GhosttyConfig.invalidateLoadCache()
config = GhosttyConfig.load()
}
.onDisappear {
panel?.hostedView.setVisibleInUI(false)
panel?.hostedView.setActive(false)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
ast-grep --pattern $'func sidebarTerminalPanel(
  for $_: $_,
  configuration: $_,
  preferredWorkingDirectory: $_
) -> $_ {
  $$$
}'
rg -nP --type=swift -C3 '\bapplySidebarTerminalConfiguration\b'

Repository: manaflow-ai/cmux

Length of output: 5695


🏁 Script executed:

rg -n 'func ensurePanel' Sources/ContentView.swift -A 20

Repository: manaflow-ai/cmux

Length of output: 1002


🏁 Script executed:

rg -n 'struct SidebarTerminalPanelView' Sources/ContentView.swift -A 80

Repository: manaflow-ai/cmux

Length of output: 3689


🏁 Script executed:

rg -n 'isVisibleInUI' Sources/Panels/TerminalPanelView.swift -B 2 -A 5

Repository: manaflow-ai/cmux

Length of output: 777


🏁 Script executed:

rg -n 'private func applySidebarTerminalConfiguration' Sources/Workspace.swift -A 30

Repository: manaflow-ai/cmux

Length of output: 1639


🏁 Script executed:

rg -n 'onChange.*isVisibleInUI\|\.onChange' Sources/Panels/TerminalPanelView.swift -B 2 -A 5 | head -80

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

rg -n 'setVisibleInUI\|setActive' Sources/Panels/TerminalPanelView.swift -B 2 -A 2

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

fd TerminalPanelView.swift Sources/

Repository: manaflow-ai/cmux

Length of output: 98


🏁 Script executed:

rg -n 'setVisibleInUI\|setActive' Sources/ --type=swift -B 1 -A 1 | head -100

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

rg -n 'hostedView\.' Sources/Panels/TerminalPanelView.swift | head -20

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

rg -n 'setVisibleInUI|setActive' . --type=swift | grep -E '(TerminalPanelView|GhosttyTerminalView|SidebarTerminalPanelView)' | head -50

Repository: manaflow-ai/cmux

Length of output: 1636


🏁 Script executed:

cat -n Sources/Panels/TerminalPanelView.swift | head -200

Repository: manaflow-ai/cmux

Length of output: 2779


🏁 Script executed:

rg -n 'hostedView.setVisibleInUI\|hostedView.setActive' . --type=swift

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

rg -n 'sidebarTerminalPanel\(' Sources/ContentView.swift -B 3 -A 3

Repository: manaflow-ai/cmux

Length of output: 1883


🏁 Script executed:

rg -n 'func updateTitle' Sources/Workspace.swift -A 5

Repository: manaflow-ai/cmux

Length of output: 1883


🏁 Script executed:

cat -n Sources/GhosttyTerminalView.swift | sed -n '12700,12730p'

Repository: manaflow-ai/cmux

Length of output: 1883


onAppear lacks visibility/active re-assertion; onChange(workspace.id) doesn't deactivate old panel.

Several lifecycle issues in SidebarTerminalPanelView:

  • onAppear calls ensurePanel() but never re-asserts visibility/active on the hosted view. After onDisappear leaves the panel deactivated, toggling back to .terminal mode will leave setVisibleInUI(false) / setActive(false) in effect until the next focus event. Add panel?.hostedView.setVisibleInUI(true); panel?.hostedView.setActive(true) in onAppear.

  • onChange(of: workspace.id) nils panel without calling setVisibleInUI(false) / setActive(false) on the previous panel, leaving the old workspace's sidebar-terminal hostedView "visible/active" from the UI's perspective. Deactivate the old panel before nil: panel?.hostedView.setVisibleInUI(false); panel?.hostedView.setActive(false).

  • onChange(of: configuration) calls ensurePanel(), which short-circuits if panel IDs match. The underlying sidebarTerminalPanel() calls applySidebarTerminalConfiguration() only on title; cwd/command are only applied at panel creation. This appears intentional (immutable after creation), but verify the expected behavior for mid-session config mutations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/ContentView.swift` around lines 10485 - 10502, In
SidebarTerminalPanelView update lifecycle handling: in the .onAppear block after
ensurePanel() re-assert the hosted view's UI state by calling
panel?.hostedView.setVisibleInUI(true) and panel?.hostedView.setActive(true); in
the .onChange(of: workspace.id) handler before setting panel = nil deactivate
the previous panel by calling panel?.hostedView.setVisibleInUI(false) and
panel?.hostedView.setActive(false); leave the .onChange(of: configuration) logic
but double-check whether applySidebarTerminalConfiguration should be invoked for
mutable fields (cwd/command) in
sidebarTerminalPanel()/applySidebarTerminalConfiguration() if configuration
changes must be applied mid-session.

Comment on lines +10791 to +10804
if !tab.isSidebarTerminalPanel(surfaceId) {
guard let tabIdForSurface = tab.surfaceIdFromPanelId(surfaceId),
let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in
tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface })
}) else {
scheduleAutomaticFirstResponderApply(reason: "ensureFocus.missingPane")
return
}

guard tab.bonsplitController.selectedTab(inPane: paneId)?.id == tabIdForSurface,
tab.bonsplitController.focusedPaneId == paneId else {
scheduleAutomaticFirstResponderApply(reason: "ensureFocus.unfocusedPane")
return
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Gate sidebar focus by the workspace’s focused panel.

isSidebarTerminalPanel(surfaceId) only proves this panel is a sidebar terminal, not that it is the active focus target. With both left/right sidebar terminals available, matchesCurrentTerminalFocusTarget can return true for multiple visible sidebar terminals, so a deferred applyFirstResponderIfNeeded() may steal keyboard focus from the sidebar terminal the user actually selected.

Consider mirroring the existing focus ownership check used for search focus (workspace.focusedPanelId == terminalSurface.id) before short-circuiting sidebar panels.

🐛 Proposed focus ownership gate
-        if !tab.isSidebarTerminalPanel(surfaceId) {
+        if tab.isSidebarTerminalPanel(surfaceId) {
+            guard tab.focusedPanelId == surfaceId else {
+                scheduleAutomaticFirstResponderApply(reason: "ensureFocus.unfocusedSidebarPanel")
+                return
+            }
+        } else {
             guard let tabIdForSurface = tab.surfaceIdFromPanelId(surfaceId),
                   let paneId = tab.bonsplitController.allPaneIds.first(where: { paneId in
                       tab.bonsplitController.tabs(inPane: paneId).contains(where: { $0.id == tabIdForSurface })
@@
-        if tab.isSidebarTerminalPanel(surfaceId) {
-            return true
-        }
+        if tab.isSidebarTerminalPanel(surfaceId) {
+            return tab.focusedPanelId == surfaceId
+        }

Also applies to: 10862-10864

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/GhosttyTerminalView.swift` around lines 10791 - 10804, The current
early-return for sidebar terminals (using isSidebarTerminalPanel(surfaceId))
must be gated by workspace.focusedPanelId == terminalSurface.id so we only
short-circuit when this sidebar panel is the workspace’s focused panel; update
the checks around isSidebarTerminalPanel(surfaceId) and
matchesCurrentTerminalFocusTarget to also verify workspace.focusedPanelId equals
the terminal surface id (use workspace.focusedPanelId and terminalSurface.id)
before skipping focus logic, and apply the same change to the other similar
block (around lines 10862-10864) so
scheduleAutomaticFirstResponderApply(reason:) / applyFirstResponderIfNeeded()
won’t steal focus from the other visible sidebar terminal.

Comment thread Sources/Workspace.swift
Comment on lines +7328 to +7333
panels[terminalPanel.id] = terminalPanel
panelTitles[terminalPanel.id] = terminalPanel.displayTitle
seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: inheritedConfig)
sidebarTerminalPanelIds[placement] = terminalPanel.id
sidebarTerminalCreationSignatures[placement] = signature
return terminalPanel

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent Bonsplit portal reconciliation from hiding sidebar terminals.

Line 7328 stores sidebar terminals in panels, but the Bonsplit portal visibility passes only consider panels present in the Bonsplit layout. Since sidebar terminals never appear in renderedVisiblePanelIdsForCurrentLayout(), these loops can mark an actively rendered sidebar terminal hidden during any layout follow-up.

Suggested fix
-        for panel in panels.values {
-            guard let terminalPanel = panel as? TerminalPanel else { continue }
+        for panel in panels.values {
+            guard let terminalPanel = panel as? TerminalPanel,
+                  !isSidebarTerminalPanel(terminalPanel.id) else { continue }
             let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id)
             if terminalPanel.hostedView.debugPortalVisibleInUI != shouldBeVisible {
                 terminalPanel.hostedView.setVisibleInUI(shouldBeVisible)
                 didChange = true
             }
-        for panel in panels.values {
-            guard let terminalPanel = panel as? TerminalPanel else { continue }
+        for panel in panels.values {
+            guard let terminalPanel = panel as? TerminalPanel,
+                  !isSidebarTerminalPanel(terminalPanel.id) else { continue }
             let shouldBeVisible = visiblePanelIds.contains(terminalPanel.id)
             let hostedView = terminalPanel.hostedView

Also applies to: 10950-10966, 10974-10986

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 7328 - 7333, The reconciliation loop
that hides panels uses renderedVisiblePanelIdsForCurrentLayout() and thus can
mark sidebar terminals (stored via panels[terminalPanel.id] and
sidebarTerminalPanelIds) as hidden because they never appear in the Bonsplit
layout; update the reconciliation logic (the code that iterates visible IDs
during Bonsplit portal reconciliation) to treat sidebar terminals as
always-visible by checking sidebarTerminalPanelIds and
sidebarTerminalCreationSignatures (or by skipping panels whose id exists in
sidebarTerminalPanelIds) before setting them hidden, so sidebarTerminal panels
created in functions like seedTerminalInheritanceFontPoints and returned from
that creation path are never toggled off during layout follow-ups.

Comment thread Sources/Workspace.swift
Comment on lines +7345 to +7354
private func applySidebarTerminalConfiguration(
_ configuration: CmuxSidebarTerminalDefinition?,
to panel: TerminalPanel
) {
guard let title = configuration?.title?
.trimmingCharacters(in: .whitespacesAndNewlines),
!title.isEmpty else { return }
panel.updateTitle(title)
panelTitles[panel.id] = panel.displayTitle
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clear stale sidebar titles when config title is removed.

This only applies non-empty configured titles. If cmux.json removes or empties title while cwd/command stay the same, the cached sidebar terminal keeps the old title. Add an explicit reset path for nil/empty titles.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 7345 - 7354,
applySidebarTerminalConfiguration currently only sets non-empty configured
titles, leaving stale titles when configuration title is removed; update the
function to handle the nil/empty case by explicitly resetting the panel title
and cache: when configuration?.title is nil or empty, call panel.updateTitle
with the panel's default/display title (or clear it) and remove any entry from
panelTitles for panel.id so the old title is not retained; keep the existing
behavior for non-empty titles that sets panel.updateTitle(title) and
panelTitles[panel.id] = panel.displayTitle.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1eee5e93fc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +622 to 624
if workspace.bonsplitPanelCount == 1 {
if state.tabManager.tabs.count > 1 {
state.tabManager.closeWorkspace(workspace)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Close only the targeted terminal in AppleScript handler

This branch now uses bonsplitPanelCount for every terminal object, so closing a sidebar terminal via AppleScript can close the whole workspace/window when there is only one Bonsplit surface. Sidebar terminals are script-addressable through scriptingTerminalPanels()/terminalPanel(for:), so a script that asks to close a specific sidebar terminal can unexpectedly terminate unrelated primary surfaces.

Useful? React with 👍 / 👎.

Comment thread Sources/Workspace.swift
)
configureTerminalPanel(terminalPanel)
applySidebarTerminalConfiguration(configuration, to: terminalPanel)
panels[terminalPanel.id] = terminalPanel

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Exclude sidebar terminals from panel-count close semantics

Adding sidebar terminals to panels here makes legacy TabManager checks that still use workspace.panels.count misclassify workspaces as multi-surface. In particular, last-surface flows (like Cmd+W "close workspace on last surface" and child-exit collapse paths) can stop closing the workspace once a sidebar terminal exists, even though only one Bonsplit surface remains.

Useful? React with 👍 / 👎.

Comment thread Sources/Workspace.swift
Comment on lines +7349 to +7352
guard let title = configuration?.title?
.trimmingCharacters(in: .whitespacesAndNewlines),
!title.isEmpty else { return }
panel.updateTitle(title)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset sidebar title when config title is removed

This only applies a new title when configuration.title is non-empty, but does nothing when the title is removed from config. Because panel reuse is keyed by command/cwd signature, reloading config after deleting sidebar.<side>.terminal.title leaves the old custom title stuck until the panel is recreated.

Useful? React with 👍 / 👎.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 10 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="Sources/ContentView.swift">

<violation number="1" location="Sources/ContentView.swift:10465">
P1: Passing `isFocused: isVisibleInUI` means the sidebar terminal always reports itself as focused whenever visible. This propagates as `isActive: true` to `GhosttyTerminalView`, which enables keyboard routing and first-responder acquisition on the underlying surface. When both a sidebar terminal and the main workspace terminal are visible, both signal active, causing focus contention. Track actual focus state (e.g., via a `@State` toggled by the `onFocus` closure and cleared on disappear) instead of tying it to visibility.</violation>
</file>

<file name="Sources/Workspace.swift">

<violation number="1" location="Sources/Workspace.swift:7349">
P2: `applySidebarTerminalConfiguration` returns early when the configured title is nil or empty, so removing `title` from `cmux.json` while keeping other fields unchanged leaves the cached panel with its old title indefinitely. Reset the panel title when no configured title is present.</violation>

<violation number="2" location="Sources/Workspace.swift:7373">
P2: Missing `syncRemotePortScanTTYs()` after removing `surfaceTTYNames` entry. The `didCloseTab` path always calls this to reconcile remote port scan TTYs after removing a TTY name; the sidebar close path should do the same to avoid stale remote port scan references.</violation>
</file>

<file name="Sources/CmuxConfig.swift">

<violation number="1" location="Sources/CmuxConfig.swift:84">
P1: Shallow merge of sidebar terminal config replaces the entire `CmuxSidebarTerminalDefinition` when a local override specifies any field. A local config like `{ "left": { "terminal": { "cwd": "app" } } }` will nil-out the global `command` and `title` because `overrides.leftTerminal` is non-nil and replaces `leftTerminal` wholesale. Deep-merge individual fields (`title`, `command`, `cwd`) to match the documented "global config merged under local config" behavior.</violation>
</file>

<file name="Sources/GhosttyTerminalView.swift">

<violation number="1" location="Sources/GhosttyTerminalView.swift:10862">
P2: `matchesCurrentTerminalFocusTarget` unconditionally returns `true` for any sidebar terminal panel. With both left and right sidebar terminals visible, deferred `applyFirstResponderIfNeeded()` calls will treat both as valid focus targets, potentially stealing keyboard focus from the sidebar terminal the user actually interacted with. Gate this on `tab.focusedPanelId == surfaceId` to match the existing focus-ownership pattern.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread Sources/ContentView.swift
TerminalPanelView(
panel: panel,
paneId: paneId,
isFocused: isVisibleInUI,

@cubic-dev-ai cubic-dev-ai Bot Apr 21, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Passing isFocused: isVisibleInUI means the sidebar terminal always reports itself as focused whenever visible. This propagates as isActive: true to GhosttyTerminalView, which enables keyboard routing and first-responder acquisition on the underlying surface. When both a sidebar terminal and the main workspace terminal are visible, both signal active, causing focus contention. Track actual focus state (e.g., via a @State toggled by the onFocus closure and cleared on disappear) instead of tying it to visibility.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/ContentView.swift, line 10465:

<comment>Passing `isFocused: isVisibleInUI` means the sidebar terminal always reports itself as focused whenever visible. This propagates as `isActive: true` to `GhosttyTerminalView`, which enables keyboard routing and first-responder acquisition on the underlying surface. When both a sidebar terminal and the main workspace terminal are visible, both signal active, causing focus contention. Track actual focus state (e.g., via a `@State` toggled by the `onFocus` closure and cleared on disappear) instead of tying it to visibility.</comment>

<file context>
@@ -10366,12 +10366,171 @@ private struct SidebarTabItemPresentationSnapshot: Equatable {
+                TerminalPanelView(
+                    panel: panel,
+                    paneId: paneId,
+                    isFocused: isVisibleInUI,
+                    isVisibleInUI: isVisibleInUI,
+                    portalPriority: 4,
</file context>
Fix with Cubic

Comment thread Sources/CmuxConfig.swift

func merging(overrides: CmuxSidebarConfiguration) -> CmuxSidebarConfiguration {
CmuxSidebarConfiguration(
leftTerminal: overrides.leftTerminal ?? leftTerminal,

@cubic-dev-ai cubic-dev-ai Bot Apr 21, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Shallow merge of sidebar terminal config replaces the entire CmuxSidebarTerminalDefinition when a local override specifies any field. A local config like { "left": { "terminal": { "cwd": "app" } } } will nil-out the global command and title because overrides.leftTerminal is non-nil and replaces leftTerminal wholesale. Deep-merge individual fields (title, command, cwd) to match the documented "global config merged under local config" behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/CmuxConfig.swift, line 84:

<comment>Shallow merge of sidebar terminal config replaces the entire `CmuxSidebarTerminalDefinition` when a local override specifies any field. A local config like `{ "left": { "terminal": { "cwd": "app" } } }` will nil-out the global `command` and `title` because `overrides.leftTerminal` is non-nil and replaces `leftTerminal` wholesale. Deep-merge individual fields (`title`, `command`, `cwd`) to match the documented "global config merged under local config" behavior.</comment>

<file context>
@@ -4,6 +4,87 @@ import Foundation
+
+    func merging(overrides: CmuxSidebarConfiguration) -> CmuxSidebarConfiguration {
+        CmuxSidebarConfiguration(
+            leftTerminal: overrides.leftTerminal ?? leftTerminal,
+            rightTerminal: overrides.rightTerminal ?? rightTerminal
+        )
</file context>
Fix with Cubic

Comment thread Sources/Workspace.swift
manualUnreadMarkedAt.removeValue(forKey: panelId)
panelSubscriptions.removeValue(forKey: panelId)
panelShellActivityStates.removeValue(forKey: panelId)
surfaceTTYNames.removeValue(forKey: panelId)

@cubic-dev-ai cubic-dev-ai Bot Apr 21, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Missing syncRemotePortScanTTYs() after removing surfaceTTYNames entry. The didCloseTab path always calls this to reconcile remote port scan TTYs after removing a TTY name; the sidebar close path should do the same to avoid stale remote port scan references.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Workspace.swift, line 7373:

<comment>Missing `syncRemotePortScanTTYs()` after removing `surfaceTTYNames` entry. The `didCloseTab` path always calls this to reconcile remote port scan TTYs after removing a TTY name; the sidebar close path should do the same to avoid stale remote port scan references.</comment>

<file context>
@@ -7252,6 +7261,126 @@ final class Workspace: Identifiable, ObservableObject {
+        manualUnreadMarkedAt.removeValue(forKey: panelId)
+        panelSubscriptions.removeValue(forKey: panelId)
+        panelShellActivityStates.removeValue(forKey: panelId)
+        surfaceTTYNames.removeValue(forKey: panelId)
+        surfaceListeningPorts.removeValue(forKey: panelId)
+        restoredTerminalScrollbackByPanelId.removeValue(forKey: panelId)
</file context>
Suggested change
surfaceTTYNames.removeValue(forKey: panelId)
surfaceTTYNames.removeValue(forKey: panelId)
syncRemotePortScanTTYs()
Fix with Cubic

Comment thread Sources/Workspace.swift
Comment on lines +7349 to +7353
guard let title = configuration?.title?
.trimmingCharacters(in: .whitespacesAndNewlines),
!title.isEmpty else { return }
panel.updateTitle(title)
panelTitles[panel.id] = panel.displayTitle

@cubic-dev-ai cubic-dev-ai Bot Apr 21, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: applySidebarTerminalConfiguration returns early when the configured title is nil or empty, so removing title from cmux.json while keeping other fields unchanged leaves the cached panel with its old title indefinitely. Reset the panel title when no configured title is present.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Workspace.swift, line 7349:

<comment>`applySidebarTerminalConfiguration` returns early when the configured title is nil or empty, so removing `title` from `cmux.json` while keeping other fields unchanged leaves the cached panel with its old title indefinitely. Reset the panel title when no configured title is present.</comment>

<file context>
@@ -7252,6 +7261,126 @@ final class Workspace: Identifiable, ObservableObject {
+        _ configuration: CmuxSidebarTerminalDefinition?,
+        to panel: TerminalPanel
+    ) {
+        guard let title = configuration?.title?
+            .trimmingCharacters(in: .whitespacesAndNewlines),
+              !title.isEmpty else { return }
</file context>
Suggested change
guard let title = configuration?.title?
.trimmingCharacters(in: .whitespacesAndNewlines),
!title.isEmpty else { return }
panel.updateTitle(title)
panelTitles[panel.id] = panel.displayTitle
let title = configuration?.title?
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let title, !title.isEmpty else {
panel.resetTitle()
panelTitles[panel.id] = panel.displayTitle
return
}
panel.updateTitle(title)
panelTitles[panel.id] = panel.displayTitle
Fix with Cubic

Comment on lines +10862 to +10863
if tab.isSidebarTerminalPanel(surfaceId) {
return true

@cubic-dev-ai cubic-dev-ai Bot Apr 21, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: matchesCurrentTerminalFocusTarget unconditionally returns true for any sidebar terminal panel. With both left and right sidebar terminals visible, deferred applyFirstResponderIfNeeded() calls will treat both as valid focus targets, potentially stealing keyboard focus from the sidebar terminal the user actually interacted with. Gate this on tab.focusedPanelId == surfaceId to match the existing focus-ownership pattern.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/GhosttyTerminalView.swift, line 10862:

<comment>`matchesCurrentTerminalFocusTarget` unconditionally returns `true` for any sidebar terminal panel. With both left and right sidebar terminals visible, deferred `applyFirstResponderIfNeeded()` calls will treat both as valid focus targets, potentially stealing keyboard focus from the sidebar terminal the user actually interacted with. Gate this on `tab.focusedPanelId == surfaceId` to match the existing focus-ownership pattern.</comment>

<file context>
@@ -10849,8 +10855,15 @@ final class GhosttySurfaceScrollView: NSView {
+            return false
+        }
+
+        if tab.isSidebarTerminalPanel(surfaceId) {
+            return true
+        }
</file context>
Suggested change
if tab.isSidebarTerminalPanel(surfaceId) {
return true
if tab.isSidebarTerminalPanel(surfaceId) {
return tab.focusedPanelId == surfaceId
}
Fix with Cubic

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9588df6640

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Sources/Workspace.swift
manualUnreadMarkedAt.removeValue(forKey: panelId)
panelSubscriptions.removeValue(forKey: panelId)
panelShellActivityStates.removeValue(forKey: panelId)
surfaceTTYNames.removeValue(forKey: panelId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Sync remote TTY state after closing sidebar terminal

When a sidebar terminal is closed in a remote workspace, this path deletes surfaceTTYNames but never calls syncRemotePortScanTTYs(), unlike the Bonsplit close paths (didCloseTab/didClosePane). That leaves the remote scanner with stale TTY registrations until some unrelated event re-syncs, so remote port detection/conflict state can remain incorrect after the sidebar terminal is gone.

Useful? React with 👍 / 👎.

Comment thread Sources/Workspace.swift
panelSubscriptions.removeValue(forKey: panelId)
panelShellActivityStates.removeValue(forKey: panelId)
surfaceTTYNames.removeValue(forKey: panelId)
surfaceListeningPorts.removeValue(forKey: panelId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recompute aggregate listening ports after sidebar close

This close path removes the panel from surfaceListeningPorts but never calls recomputeListeningPorts(), so listeningPorts can retain stale ports from the closed sidebar terminal. PortScanner.unregisterPanel only drops internal tracking and does not emit an onPortsUpdated callback, so there is no guaranteed later recomputation to correct the published aggregate.

Useful? React with 👍 / 👎.

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview — 9588df66 Deployed Apr 21, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants